diff --git a/assets/css/app.css b/assets/css/app.css index d22a3fe..6e4a931 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -621,3 +621,116 @@ html, body { border: 1px solid var(--mx-border2); color: var(--mx-accent2); } + +/* ── Tweet Detail Page ── */ +.mx-detail { + padding: 1rem 1.5rem; +} + +.mx-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.mx-back-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--mx-fg2); + font-size: 0.85rem; + text-decoration: none; + padding: 0.4rem 0.75rem; + border-radius: var(--mx-radius-sm); + border: 1px solid var(--mx-border); + transition: background 0.15s; +} +.mx-back-btn:hover { background: var(--mx-surface2); } + +.mx-detail-author { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.mx-detail-body { + padding: 0.5rem 0; +} + +.mx-detail-content { + font-size: 1.1rem; + line-height: 1.6; + color: var(--mx-fg); + margin-bottom: 1rem; + white-space: pre-wrap; + word-break: break-word; +} + +.mx-detail-media { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; +} + +/* ── Clickable media thumb (used in detail view) ── */ +.mx-media-thumb { + background: none; + border: none; + padding: 0; + cursor: pointer; + border-radius: var(--mx-radius-sm); + overflow: hidden; + display: block; + width: 100%; +} +.mx-media-thumb img, +.mx-media-thumb video { + width: 100%; + border-radius: var(--mx-radius-sm); + display: block; +} +.mx-media-thumb:hover { opacity: 0.85; } + +/* ── Media Lightbox ── */ +.mx-lightbox { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.92); + display: flex; + align-items: center; + justify-content: center; +} + +.mx-lightbox-close { + position: absolute; + top: 1rem; + right: 1rem; + background: rgba(255, 255, 255, 0.1); + border: none; + color: #fff; + cursor: pointer; + font-size: 1.1rem; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +.mx-lightbox-close:hover { background: rgba(255, 255, 255, 0.2); } + +.mx-lightbox-content { + max-width: 90vw; + max-height: 90vh; +} + +.mx-lightbox-media { + max-width: 90vw; + max-height: 90vh; + border-radius: var(--mx-radius-sm); + display: block; +} diff --git a/assets/js/index.tsx b/assets/js/index.tsx index 421c325..91a479c 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; +import { createPortal } from "react-dom"; import { QueryClient, QueryClientProvider, @@ -372,7 +373,11 @@ function TweetCard({ tweet }: { tweet: Tweet }) { } return ( -
+
{ window.location.href = `/feed/${tweet.id}`; }} + >
M
@@ -386,7 +391,8 @@ function TweetCard({ tweet }: { tweet: Tweet }) { +
e.stopPropagation()}> + {/\.(mp4|mov)$/i.test(item.s3Key) ? ( +
+ , + document.body + ); +} + +function TweetDetail({ tweetId }: { tweetId: string }) { + const { userId: currentUserId } = useContext(AuthCtx); + const [lightboxItem, setLightboxItem] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [editing, setEditing] = useState(false); + const [editText, setEditText] = useState(""); + const [error, setError] = useState(null); + const qc = useQueryClient(); + const assetHost = getAssetHost(); + + const { data: tweet, isLoading, isError } = useQuery({ + queryKey: ["tweet", tweetId], + queryFn: async () => { + const res = await readTweet({ + fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], + filter: { id: { eq: tweetId } }, + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error("Failed to load tweet"); + const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; + return (results[0] as Tweet) ?? null; + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() }); + if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete"); + }, + onSuccess: () => { window.location.href = "/feed"; }, + onError: (e: Error) => setError(e.message), + }); + + const updateMutation = useMutation({ + mutationFn: async (content: string) => { + const res = await updateTweet({ + identity: tweetId, + 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: ["tweet", tweetId] }); + setEditing(false); + setError(null); + }, + onError: (e: Error) => setError(e.message), + }); + + const likeMutation = useMutation({ + mutationFn: async () => { + if (!tweet) return; + const action = tweet.likedByMe ? unlikeTweet : likeTweet; + const res = await action({ identity: tweetId, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() }); + if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["tweet", tweetId] }), + onError: (e: Error) => setError(e.message), + }); + + if (isLoading) return ; + if (isError || !tweet) return ; + + const canModify = !!currentUserId && tweet.userId === currentUserId; + const canLike = !!currentUserId; + + return ( +
+
+ + + + + Back + + {canModify && ( +
+ + +
+ )} +
+ +
+
+
+ M +
+ {tweet.userEmail ?? "@mixer"} +
+ + {editing ? ( +
+