Improved UI and compliance with api endpoint restrictions
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function getImageFilename(post) {
|
||||
return post.image || post.image_filename || post.filename || null;
|
||||
}
|
||||
|
||||
function getAuthorName(post, authorId) {
|
||||
return post.author?.name || post.author_name || (post.author_username ? `@${post.author_username}` : `User ${authorId || ""}`);
|
||||
}
|
||||
|
||||
function getAuthorUsername(post) {
|
||||
return post.author_username || post.author?.username || null;
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name) return "?";
|
||||
const cleaned = name.replace(/^@/, "");
|
||||
return cleaned
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return String(timestamp);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
} catch {
|
||||
return String(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function AuthenticatedImage({ filename, alt, loadImage }) {
|
||||
const [source, setSource] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl = "";
|
||||
|
||||
if (!filename) {
|
||||
setSource("");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
loadImage(filename)
|
||||
.then((blob) => {
|
||||
if (active) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSource(objectUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setError("Image unavailable.");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [filename, loadImage]);
|
||||
|
||||
if (error) {
|
||||
return <p className="muted">{error}</p>;
|
||||
}
|
||||
return source ? <img className="post-image" src={source} alt={alt} loading="lazy" decoding="async" /> : null;
|
||||
}
|
||||
|
||||
export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
|
||||
const isOwner = authorId !== undefined && String(authorId) === String(currentUserId);
|
||||
const imageFilename = getImageFilename(post);
|
||||
const authorName = getAuthorName(post, authorId);
|
||||
const authorUsername = getAuthorUsername(post);
|
||||
const formattedDate = formatDate(post.created_at || post.createdAt);
|
||||
|
||||
async function handleEdit(event) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
if (form.image && form.removeImage) {
|
||||
setError("An image cannot be uploaded and removed at the same time.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onEdit(post.id, form);
|
||||
setEditing(false);
|
||||
} catch (editError) {
|
||||
setError(editError.message || "Unable to edit post.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm("Delete this post?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onDelete(post.id);
|
||||
} catch (deleteError) {
|
||||
setError(deleteError.message || "Unable to delete post.");
|
||||
}
|
||||
}
|
||||
|
||||
const profilePath = authorId ? `/profile/${authorId}` : "/profile";
|
||||
|
||||
return (
|
||||
<article className="panel post-card">
|
||||
<div className="post-top post-author">
|
||||
<Link to={profilePath} className="author-link" title={`View ${authorName}'s profile`}>
|
||||
<div className="author-avatar">{getInitials(authorName)}</div>
|
||||
<div className="author-meta">
|
||||
<span className="author-name">{authorName}</span>
|
||||
{authorUsername && !authorName.startsWith("@") ? (
|
||||
<span className="author-handle">@{authorUsername}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="post-top-right">
|
||||
{formattedDate ? <span className="post-date">{formattedDate}</span> : null}
|
||||
{isOwner && !editing ? (
|
||||
<div className="post-owner-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn edit-icon-btn"
|
||||
title="Edit post"
|
||||
aria-label="Edit post"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn delete-icon-btn"
|
||||
title="Delete post"
|
||||
aria-label="Delete post"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={handleEdit} className="post-form">
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<input value={form.title} onChange={(event) => setForm((previous) => ({ ...previous, title: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Text</span>
|
||||
<textarea value={form.text} onChange={(event) => setForm((previous) => ({ ...previous, text: event.target.value }))} rows="4" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Replace image (optional)</span>
|
||||
<input type="file" accept="image/*" onChange={(event) => setForm((previous) => ({ ...previous, image: event.target.files?.[0] || null }))} />
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.removeImage} onChange={(event) => setForm((previous) => ({ ...previous, removeImage: event.target.checked }))} />
|
||||
Remove current image
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="post-copy">
|
||||
{post.title ? <h3>{post.title}</h3> : null}
|
||||
<p>{post.text ?? post.content}</p>
|
||||
</div>
|
||||
{imageFilename ? <AuthenticatedImage filename={imageFilename} alt={post.title || "Post image"} loadImage={loadImage} /> : null}
|
||||
</>
|
||||
)}
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user