Compare commits
2 Commits
ae35600822
...
0f41e86cf0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f41e86cf0 | |||
| 0ac0b68029 |
@@ -621,3 +621,116 @@ html, body {
|
|||||||
border: 1px solid var(--mx-border2);
|
border: 1px solid var(--mx-border2);
|
||||||
color: var(--mx-accent2);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
|
|
||||||
export type UUID = string;
|
export type UUID = string;
|
||||||
|
export type UtcDateTimeUsec = string;
|
||||||
|
|
||||||
// media Schema
|
// media Schema
|
||||||
export type mediaResourceSchema = {
|
export type mediaResourceSchema = {
|
||||||
@@ -31,13 +32,15 @@ export type mediaAttributesOnlySchema = {
|
|||||||
// tweets Schema
|
// tweets Schema
|
||||||
export type tweetsResourceSchema = {
|
export type tweetsResourceSchema = {
|
||||||
__type: "Resource";
|
__type: "Resource";
|
||||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "state" | "likedByMe";
|
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "likedByMe" | "userEmail";
|
||||||
id: UUID;
|
id: UUID;
|
||||||
content: string;
|
content: string;
|
||||||
likes: number;
|
likes: number;
|
||||||
userId: UUID;
|
userId: UUID;
|
||||||
|
insertedAt: UtcDateTimeUsec;
|
||||||
state: "posted" | "drafted";
|
state: "posted" | "drafted";
|
||||||
likedByMe: boolean;
|
likedByMe: boolean;
|
||||||
|
userEmail: string | null;
|
||||||
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,11 +48,12 @@ export type tweetsResourceSchema = {
|
|||||||
|
|
||||||
export type tweetsAttributesOnlySchema = {
|
export type tweetsAttributesOnlySchema = {
|
||||||
__type: "Resource";
|
__type: "Resource";
|
||||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "state";
|
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state";
|
||||||
id: UUID;
|
id: UUID;
|
||||||
content: string;
|
content: string;
|
||||||
likes: number;
|
likes: number;
|
||||||
userId: UUID;
|
userId: UUID;
|
||||||
|
insertedAt: UtcDateTimeUsec;
|
||||||
state: "posted" | "drafted";
|
state: "posted" | "drafted";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,12 +125,29 @@ export type tweetsFilterInput = {
|
|||||||
in?: Array<UUID>;
|
in?: Array<UUID>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
insertedAt?: {
|
||||||
|
eq?: UtcDateTimeUsec;
|
||||||
|
notEq?: UtcDateTimeUsec;
|
||||||
|
greaterThan?: UtcDateTimeUsec;
|
||||||
|
greaterThanOrEqual?: UtcDateTimeUsec;
|
||||||
|
lessThan?: UtcDateTimeUsec;
|
||||||
|
lessThanOrEqual?: UtcDateTimeUsec;
|
||||||
|
in?: Array<UtcDateTimeUsec>;
|
||||||
|
};
|
||||||
|
|
||||||
state?: {
|
state?: {
|
||||||
eq?: "posted" | "drafted";
|
eq?: "posted" | "drafted";
|
||||||
notEq?: "posted" | "drafted";
|
notEq?: "posted" | "drafted";
|
||||||
in?: Array<"posted" | "drafted">;
|
in?: Array<"posted" | "drafted">;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
userEmail?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
likedByMe?: {
|
likedByMe?: {
|
||||||
eq?: boolean;
|
eq?: boolean;
|
||||||
notEq?: boolean;
|
notEq?: boolean;
|
||||||
@@ -141,14 +162,14 @@ export type tweetsFilterInput = {
|
|||||||
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", "state", "likedByMe", "user", "media"] as const;
|
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe", "user", "media"] as const;
|
||||||
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
export type tweetsFilterField = (typeof tweetsFilterFields)[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", "state", "likedByMe"] as const;
|
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe"] as const;
|
||||||
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, useEffect } from "react";
|
import React, { createContext, useContext, useState, useRef, useEffect } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
QueryClient,
|
QueryClient,
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
@@ -33,6 +34,8 @@ type Tweet = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
state: string;
|
state: string;
|
||||||
media?: MediaItem[];
|
media?: MediaItem[];
|
||||||
|
userEmail?: string | null;
|
||||||
|
insertedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Auth context ───────────────────────────────────────────────────────────────
|
// ── Auth context ───────────────────────────────────────────────────────────────
|
||||||
@@ -370,13 +373,17 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="mx-tweet">
|
<article
|
||||||
|
className="mx-tweet"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
||||||
|
>
|
||||||
<div className="mx-tweet-avatar">
|
<div className="mx-tweet-avatar">
|
||||||
<span>M</span>
|
<span>M</span>
|
||||||
</div>
|
</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">@mixer</span>
|
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span>
|
||||||
<span className="mx-tweet-dot">·</span>
|
<span className="mx-tweet-dot">·</span>
|
||||||
<span className="mx-tweet-time">{timeAgo()}</span>
|
<span className="mx-tweet-time">{timeAgo()}</span>
|
||||||
{canModify && (
|
{canModify && (
|
||||||
@@ -384,7 +391,8 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
<button
|
<button
|
||||||
className="mx-action-btn"
|
className="mx-action-btn"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
setEditText(tweet.content);
|
setEditText(tweet.content);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
@@ -397,7 +405,8 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
<button
|
<button
|
||||||
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||||
title={confirmDelete ? "Confirm delete" : "Delete"}
|
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
if (!confirmDelete) {
|
if (!confirmDelete) {
|
||||||
setConfirmDelete(true);
|
setConfirmDelete(true);
|
||||||
setTimeout(() => setConfirmDelete(false), 3000);
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
@@ -462,7 +471,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
<div className="mx-tweet-footer">
|
<div className="mx-tweet-footer">
|
||||||
<button
|
<button
|
||||||
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||||
onClick={() => likeMutation.mutate()}
|
onClick={(e) => { e.stopPropagation(); likeMutation.mutate(); }}
|
||||||
disabled={!canLike || likeMutation.isPending}
|
disabled={!canLike || likeMutation.isPending}
|
||||||
title={
|
title={
|
||||||
canLike
|
canLike
|
||||||
@@ -485,13 +494,222 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaLightbox({ item, onClose }: { item: MediaItem; onClose: () => void }) {
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="mx-lightbox" onClick={onClose}>
|
||||||
|
<button className="mx-lightbox-close" onClick={onClose}>✕</button>
|
||||||
|
<div className="mx-lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/\.(mp4|mov)$/i.test(item.s3Key) ? (
|
||||||
|
<video src={`${assetHost}/${item.s3Key}`} controls autoPlay className="mx-lightbox-media" />
|
||||||
|
) : (
|
||||||
|
<img src={`${assetHost}/${item.s3Key}`} alt="" className="mx-lightbox-media" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||||
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
|
const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editText, setEditText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(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 <Spinner />;
|
||||||
|
if (isError || !tweet) return <ErrorBanner message="Could not load tweet" />;
|
||||||
|
|
||||||
|
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||||
|
const canLike = !!currentUserId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-detail">
|
||||||
|
<div className="mx-detail-header">
|
||||||
|
<a href="/feed" className="mx-back-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
{canModify && (
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
className="mx-action-btn"
|
||||||
|
title="Edit"
|
||||||
|
onClick={() => { setEditText(tweet.content); setEditing(true); }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||||
|
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
|
} else {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<span style={{ fontSize: "0.65rem" }}>…</span>
|
||||||
|
) : confirmDelete ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-detail-body">
|
||||||
|
<div className="mx-detail-author">
|
||||||
|
<div className="mx-tweet-avatar">
|
||||||
|
<span>M</span>
|
||||||
|
</div>
|
||||||
|
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="mx-edit-area">
|
||||||
|
<textarea
|
||||||
|
className="mx-edit-textarea"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
|
<div className="mx-edit-footer">
|
||||||
|
<button className="mx-btn-cancel" onClick={() => { setEditing(false); setError(null); }}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="mx-btn-save"
|
||||||
|
onClick={() => { const t = editText.trim(); if (t) updateMutation.mutate(t); }}
|
||||||
|
disabled={!editText.trim() || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mx-detail-content">{tweet.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tweet.media && tweet.media.length > 0 && (
|
||||||
|
<div className="mx-detail-media">
|
||||||
|
{tweet.media.map((m) => (
|
||||||
|
<button key={m.id} className="mx-media-thumb" onClick={() => setLightboxItem(m)}>
|
||||||
|
{/\.(mp4|mov)$/i.test(m.s3Key) ? (
|
||||||
|
<video src={`${assetHost}/${m.s3Key}`} />
|
||||||
|
) : (
|
||||||
|
<img src={`${assetHost}/${m.s3Key}`} alt="" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-tweet-footer" style={{ marginTop: "1rem" }}>
|
||||||
|
<button
|
||||||
|
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||||
|
onClick={() => likeMutation.mutate()}
|
||||||
|
disabled={!canLike || likeMutation.isPending}
|
||||||
|
title={canLike ? (tweet.likedByMe ? "Remove like" : "Like post") : "Sign in to like posts"}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||||
|
</svg>
|
||||||
|
<span>{tweet.likes}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightboxItem && <MediaLightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Feed() {
|
function Feed() {
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ["tweets"],
|
queryKey: ["tweets"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await readTweet({
|
const res = await readTweet({
|
||||||
fields: ["id", "content", "likes", "likedByMe", "userId", "state", { media: ["id", "s3Key"] }],
|
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
sort: "-id",
|
sort: "-insertedAt",
|
||||||
headers: buildCSRFHeaders(),
|
headers: buildCSRFHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.success) throw new Error("Failed to load tweets");
|
if (!res.success) throw new Error("Failed to load tweets");
|
||||||
@@ -558,6 +776,7 @@ 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 tweetId = appEl.dataset.tweetId || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthCtx.Provider value={{ email, userId }}>
|
<AuthCtx.Provider value={{ email, userId }}>
|
||||||
@@ -569,7 +788,7 @@ function App() {
|
|||||||
<span className="mx-logo-text">Mixer</span>
|
<span className="mx-logo-text">Mixer</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mx-nav">
|
<nav className="mx-nav">
|
||||||
<a className="mx-nav-item mx-nav-active" href="#">
|
<a className="mx-nav-item mx-nav-active" href="/feed">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -593,25 +812,36 @@ function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="mx-main">
|
<main className="mx-main">
|
||||||
<header className="mx-header">
|
{tweetId ? (
|
||||||
<h1 className="mx-header-title">Feed</h1>
|
<>
|
||||||
<RefreshButton />
|
<header className="mx-header">
|
||||||
</header>
|
<h1 className="mx-header-title">Tweet</h1>
|
||||||
|
</header>
|
||||||
|
<TweetDetail tweetId={tweetId} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Feed</h1>
|
||||||
|
<RefreshButton />
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="mx-compose-wrapper">
|
<div className="mx-compose-wrapper">
|
||||||
{email ? (
|
{email ? (
|
||||||
<ComposeTweet />
|
<ComposeTweet />
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-signin-cta">
|
<div className="mx-signin-cta">
|
||||||
<p>Sign in to start mixing.</p>
|
<p>Sign in to start mixing.</p>
|
||||||
<a className="mx-btn-post" href="/register">Sign in</a>
|
<a className="mx-btn-post" href="/register">Sign in</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-divider" />
|
<div className="mx-divider" />
|
||||||
|
|
||||||
<Feed />
|
<Feed />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div className="mx-rightbar">
|
<div className="mx-rightbar">
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
allow_nil? false
|
allow_nil? false
|
||||||
public? true
|
public? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_timestamp :inserted_at do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
update_timestamp :updated_at
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
@@ -146,6 +152,12 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
has_many :tweet_likes, Mixer.Posts.TweetLike
|
has_many :tweet_likes, Mixer.Posts.TweetLike
|
||||||
end
|
end
|
||||||
|
|
||||||
|
calculations do
|
||||||
|
calculate :user_email, :string, expr(user.email) do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
aggregates do
|
aggregates do
|
||||||
exists :liked_by_me, :tweet_likes do
|
exists :liked_by_me, :tweet_likes do
|
||||||
public? true
|
public? true
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ defmodule MixerWeb.PageController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
|
render_spa(conn, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show(conn, %{"tweet_id" => tweet_id}) do
|
||||||
|
render_spa(conn, tweet_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_spa(conn, tweet_id) do
|
||||||
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
|
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
|
||||||
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
|
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
|
||||||
|
|
||||||
@@ -13,7 +21,8 @@ defmodule MixerWeb.PageController do
|
|||||||
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|
||||||
|> render(:index,
|
|> render(:index,
|
||||||
current_user: conn.assigns[:current_user],
|
current_user: conn.assigns[:current_user],
|
||||||
media_host: "#{asset_host}/#{bucket}"
|
media_host: "#{asset_host}/#{bucket}",
|
||||||
|
tweet_id: tweet_id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<div id="app"
|
<div 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-asset-host={@media_host}>
|
data-asset-host={@media_host}
|
||||||
|
data-tweet-id={@tweet_id || ""}>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ defmodule MixerWeb.Router do
|
|||||||
|
|
||||||
get "/", PageController, :home
|
get "/", PageController, :home
|
||||||
get "/feed", PageController, :index
|
get "/feed", PageController, :index
|
||||||
|
get "/feed/:tweet_id", PageController, :show
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
defmodule Mixer.Repo.Migrations.AddTimestampsToTweets 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(:tweets) do
|
||||||
|
add :inserted_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
|
||||||
|
add :updated_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:tweets) do
|
||||||
|
remove :updated_at
|
||||||
|
remove :inserted_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
123
priv/resource_snapshots/repo/tweets/20260401154313.json
Normal file
123
priv/resource_snapshots/repo/tweets/20260401154313.json
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"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": "content",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "0",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "likes",
|
||||||
|
"type": "bigint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": {
|
||||||
|
"deferrable": false,
|
||||||
|
"destination_attribute": "id",
|
||||||
|
"destination_attribute_default": null,
|
||||||
|
"destination_attribute_generated": null,
|
||||||
|
"index?": false,
|
||||||
|
"match_type": null,
|
||||||
|
"match_with": null,
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"name": "tweets_user_id_fkey",
|
||||||
|
"on_delete": null,
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "user_id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "inserted_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "updated_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "\"drafted\"",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "state",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "5CA1873A0545807862B314C4E49F4E4538905E9BD3B40C33EE1AFE6ABD60538C",
|
||||||
|
"identities": [],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mixer.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "tweets"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user