Adding likes to tweets
This commit is contained in:
@@ -474,6 +474,46 @@ html, body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mx-tweet-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
.mx-like-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--mx-border2);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--mx-surface2) 72%, transparent);
|
||||
color: var(--mx-fg2);
|
||||
cursor: pointer;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
padding: 0.45rem 0.75rem;
|
||||
transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.mx-like-btn:hover:not(:disabled) {
|
||||
color: var(--mx-red);
|
||||
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
|
||||
background: color-mix(in oklch, var(--mx-red) 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mx-like-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mx-like-btn-active {
|
||||
color: var(--mx-red);
|
||||
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
|
||||
background: color-mix(in oklch, var(--mx-red) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ── Edit ── */
|
||||
.mx-edit-area { margin-top: 0.25rem; }
|
||||
|
||||
|
||||
@@ -435,6 +435,74 @@ export async function validateDestroyTweet(
|
||||
}
|
||||
|
||||
|
||||
export type LikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
export type InferLikeTweetResult<
|
||||
Fields extends LikeTweetFields | undefined,
|
||||
> = InferResult<tweetsResourceSchema, Fields>;
|
||||
|
||||
export type LikeTweetResult<Fields extends LikeTweetFields | undefined = undefined> = | { success: true; data: InferLikeTweetResult<Fields>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
*/
|
||||
export async function likeTweet<Fields extends LikeTweetFields | undefined = undefined>(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID;
|
||||
fields?: Fields;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<LikeTweetResult<Fields extends undefined ? [] : Fields>> {
|
||||
const payload = {
|
||||
action: "like_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity,
|
||||
...(config.fields !== undefined && { fields: config.fields })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<LikeTweetResult<Fields extends undefined ? [] : Fields>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateLikeTweet(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID | string;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "like_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
|
||||
@@ -536,11 +604,76 @@ export async function validateReadTweet(
|
||||
}
|
||||
|
||||
|
||||
export type UnlikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
export type InferUnlikeTweetResult<
|
||||
Fields extends UnlikeTweetFields | undefined,
|
||||
> = InferResult<tweetsResourceSchema, Fields>;
|
||||
|
||||
export type UnlikeTweetResult<Fields extends UnlikeTweetFields | undefined = undefined> = | { success: true; data: InferUnlikeTweetResult<Fields>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
*/
|
||||
export async function unlikeTweet<Fields extends UnlikeTweetFields | undefined = undefined>(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID;
|
||||
fields?: Fields;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<UnlikeTweetResult<Fields extends undefined ? [] : Fields>> {
|
||||
const payload = {
|
||||
action: "unlike_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity,
|
||||
...(config.fields !== undefined && { fields: config.fields })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<UnlikeTweetResult<Fields extends undefined ? [] : Fields>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateUnlikeTweet(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID | string;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "unlike_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type UpdateTweetInput = {
|
||||
content?: string;
|
||||
likes?: number;
|
||||
userId?: UUID;
|
||||
state?: "posted" | "drafted";
|
||||
};
|
||||
|
||||
export type UpdateTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
@@ -31,12 +31,13 @@ export type mediaAttributesOnlySchema = {
|
||||
// tweets Schema
|
||||
export type tweetsResourceSchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "state";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "state" | "likedByMe";
|
||||
id: UUID;
|
||||
content: string;
|
||||
likes: number;
|
||||
userId: UUID;
|
||||
state: "posted" | "drafted";
|
||||
likedByMe: boolean;
|
||||
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
||||
};
|
||||
|
||||
@@ -126,6 +127,11 @@ export type tweetsFilterInput = {
|
||||
in?: Array<"posted" | "drafted">;
|
||||
};
|
||||
|
||||
likedByMe?: {
|
||||
eq?: boolean;
|
||||
notEq?: boolean;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
media?: mediaFilterInput;
|
||||
|
||||
@@ -135,14 +141,14 @@ export type tweetsFilterInput = {
|
||||
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
||||
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
||||
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "media"] as const;
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "likedByMe", "user", "media"] as const;
|
||||
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
||||
|
||||
|
||||
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
||||
export type mediaSortField = (typeof mediaSortFields)[number];
|
||||
|
||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "state"] as const;
|
||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "state", "likedByMe"] as const;
|
||||
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
createTweet,
|
||||
readTweet,
|
||||
destroyTweet,
|
||||
likeTweet,
|
||||
unlikeTweet,
|
||||
updateTweet,
|
||||
buildCSRFHeaders,
|
||||
} from "./ash_rpc";
|
||||
@@ -23,7 +25,15 @@ const queryClient = new QueryClient({
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type MediaItem = { id: string; s3Key: string };
|
||||
type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] };
|
||||
type Tweet = {
|
||||
id: string;
|
||||
content: string;
|
||||
likes: number;
|
||||
likedByMe?: boolean;
|
||||
userId: string;
|
||||
state: string;
|
||||
media?: MediaItem[];
|
||||
};
|
||||
|
||||
// ── Auth context ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -298,6 +308,7 @@ function TweetMedia({ media }: { media: MediaItem[] }) {
|
||||
function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||
const canLike = !!currentUserId;
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(tweet.content);
|
||||
@@ -335,6 +346,23 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
|
||||
const res = await action({
|
||||
identity: tweet.id,
|
||||
fields: ["id", "likes", "likedByMe"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
function saveEdit() {
|
||||
const trimmed = editText.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -431,6 +459,26 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
<TweetMedia media={tweet.media} />
|
||||
)}
|
||||
|
||||
<div className="mx-tweet-footer">
|
||||
<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>
|
||||
</article>
|
||||
@@ -442,7 +490,7 @@ function Feed() {
|
||||
queryKey: ["tweets"],
|
||||
queryFn: async () => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }],
|
||||
fields: ["id", "content", "likes", "likedByMe", "userId", "state", { media: ["id", "s3Key"] }],
|
||||
sort: "-id",
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user