209 lines
7.0 KiB
React
209 lines
7.0 KiB
React
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>
|
|
);
|
|
}
|