Files
filing-cabinet-web/src/components/PostCard.jsx
T

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>
);
}