Previously, we built a simple Blog Post web app, applying CRUD operations with Laravel 8, Bootstrap 5, and React. This is the extended part where we improve the previous app by adding image to the post and implementing toggling views for all the posts, whether in card form or group list.
This project is a continuation of a previous project and most of the information can be referred from Creating CRUD Web App with Laravel 8 & React.
Bootstrap Icons is a collection of free, high-quality SVG icons that are designed to work seamlessly with Bootstrap.
Install bootstrap-icons
using below command:
npm install bootstrap-icons
To use the bootstrap-icons
, we need to import it to the app.scss
which can be found in resources/sass
. Copy and paste below code into app.scss
:
// Bootstrap Icons
@import "~bootstrap-icons/font/bootstrap-icons";
First we update the AllPostPage.js
with some few changes:
<div className="d-flex flex-row-reverse">
<div className="btn-group btn-group-lg mb-3">
<a
className={`btn btn-outline-secondary btn-sm ${
this.state.displayMode === "card"
? "active"
: ""
}`}
onClick={this.toggleDisplayMode}
>
<i className="bi bi-grid-fill"></i>
</a>
<a
className={`btn btn-outline-secondary btn-sm ${
this.state.displayMode === "list"
? "active"
: ""
}`}
onClick={this.toggleDisplayMode}
>
<i className="bi bi-list"></i>
</a>
</div>
</div>
Below code is ternary operator used to toggle between grid (card) or list view:
{displayMode === "card" ? (
<div className="row g-4 mt-1">
{...Rest of the card view code}
</div>
) : (
<div>
{...Rest of the grid view code}
</div>
)}
img
tag of image of the post
{post.image != undefined && (
<img
src={`storage/images/${post.image}`}
className="card-img-top img-fluid"
/>
)}
Full code can be referred below:
import React, { Component } from "react";
import axios from "axios";
import moment from "moment";
import { Link } from "react-router-dom";
class AllPostPage extends Component {
constructor(props) {
super(props);
this.state = {
posts: undefined,
displayMode: "card", // Default mode is card view
};
}
componentDidMount() {
this.callAPI();
}
callAPI() {
let url = `/api/posts`;
axios
.get(url)
.then((res) => {
this.setState({
posts: res.data.posts,
});
})
.catch((e) => {});
}
// Function to toggle between card and list mode
toggleDisplayMode = () => {
const newMode = this.state.displayMode === "card" ? "list" : "card";
this.setState({ displayMode: newMode });
};
render() {
const { posts, displayMode } = this.state;
return (
<div className="container mt-4">
<div className="d-flex flex-direction-row justify-content-between align-items-center">
<h1 className="mb-4">Post List</h1>
<div className="d-flex flex-direction-row justify-content-between">
<Link className="btn btn-primary" to={"/create"}>
Add a new one?
</Link>
</div>
</div>
<hr />
<div className="d-flex flex-row-reverse">
<div className="btn-group btn-group-lg mb-3">
<a
className={`btn btn-outline-secondary btn-sm ${
this.state.displayMode === "card"
? "active"
: ""
}`}
onClick={this.toggleDisplayMode}
>
<i className="bi bi-grid-fill"></i>
</a>
<a
className={`btn btn-outline-secondary btn-sm ${
this.state.displayMode === "list"
? "active"
: ""
}`}
onClick={this.toggleDisplayMode}
>
<i className="bi bi-list"></i>
</a>
</div>
</div>
{displayMode === "card" ? (
<div className="row g-4 mt-1">
{posts !== undefined && posts.length > 0 ? (
posts.map((post, index) => {
return (
<div
className="col-lg-4"
key={post.id}
id={index}
>
<div className="card shadow">
{post.image != undefined && (
<img
src={`storage/images/${post.image}`}
className="card-img-top img-fluid"
/>
)}
<div className="card-body">
<p className="btn btn-success rounded-pill btn-sm">
{post.category}
</p>
<Link
to={`/posts/${post.id}`}
style={{
textDecoration: "none",
}}
>
<div className="card-title fw-bold text-primary h4">
{post.title}
</div>
</Link>
<p className="text-secondary">
{`${post.content.substring(
0,
30
)}${
post.content.length > 30
? "... "
: ""
}`}
{post.content.length >
30 && (
<Link
to={`/posts/${post.id}`}
style={{
textDecoration:
"none",
}}
>
Read More
</Link>
)}
</p>
</div>
</div>
</div>
);
})
) : (
<h2 className="text-center text-secondary p-4">
No post found in the database!
</h2>
)}
</div>
) : (
<div>
{posts !== undefined && posts.length > 0 ? (
<ul className="list-group">
{posts.map((post, index) => {
return (
<li
key={post.id}
className="list-group-item list-group-item-action"
>
<Link
to={`/posts/${post.id}`}
style={{
textDecoration: "none",
}}
>
<div className="d-flex w-100 justify-content-between align-items-center">
<h5 className="mb-1">
{post.title}
</h5>
<span className="badge bg-success rounded-pill">
{post.category}
</span>
</div>
</Link>
</li>
);
})}
</ul>
) : (
<p className="text-center text-secondary mt-4">
No posts found in the database!
</p>
)}
</div>
)}
</div>
);
}
}
export default AllPostPage;
For this page we only need to add the img
tag PostPage.js
since we will have image on our new post:
{post.image != undefined && (
<img
src={`storage/images/${post.image}`}
className="card-img-top img-fluid"
/>
)}
Full code is shown as below:
import React, { Component } from "react";
import moment from "moment";
import axios from "axios";
import { Link, withRouter } from "react-router-dom";
class PostPage extends Component {
constructor(props) {
super(props);
this.state = {
post: undefined,
};
}
componentDidMount() {
this.callAPI();
}
callAPI() {
const { id } = this.props.match.params;
let url = `/api/posts/${id}`;
console.log(`url`, url);
axios
.get(url)
.then((res) => {
console.log(url, res.data.post);
this.setState({
post: res.data.post,
});
})
.catch((e) => {});
}
handleDelete = () => {
const { id } = this.props.match.params;
let result = confirm("Are you want to delete ");
if (result == false) {
return;
}
axios
.delete(`/api/posts/${id}`)
.then((res) => {
// Handle the successful deletion, e.g., redirect to another page.
// You may want to add error handling here as well.
this.props.history.push("/posts"); // Redirect to the home page or another appropriate location.
})
.catch((error) => {
console.error("Delete request error: ", error);
});
};
render() {
const { post } = this.state;
if (!post) {
// Render a loading message or spinner while waiting for the API response.
return <div>Loading...</div>;
}
return (
<div className="row my-4">
<div className="col-lg-8 mx-auto">
<div className="card shadow">
{post.image != undefined && (
<img
src={`storage/images/${post.image}`}
className="card-img-top img-fluid"
/>
)}
<div className="card-body p-5">
<div className="d-flex justify-content-between align-items-center">
<p className="btn btn-dark rounded-pill">
{post.category}
</p>
<p className="lead">
{moment(post.created_at).fromNow()}
</p>
</div>
<hr />
<h3 className="fw-bold text-primary">
{post.title}
</h3>
<p>{post.content}</p>
</div>
<div className="card-footer px-5 py-3 d-flex justify-content-end">
<Link
to={`/posts/${post.id}/edit`}
className="btn btn-success rounded-pill me-2"
>
Edit
</Link>
<button
onClick={this.handleDelete}
className="btn btn-danger rounded-pill"
>
Delete
</button>
</div>
</div>
</div>
</div>
);
}
}
export default withRouter(PostPage);
There are some changes made for creating and updating a post. Those changes are:
FormData()
instead of params
.
const { title, category, content, image, isEdit } = this.state;
const formData = new FormData();
formData.set("title", title);
formData.set("category", category);
formData.set("content", content);
if (typeof image !== "string") {
formData.set("image", image);
} else {
formData.set("image", "");
}
<div className="my-2">
<input
type="file"
name="image"
onChange={this.handleFileChange}
className={`form-control ${
errors.image ? "is-invalid" : ""
}`}
/>
{isEdit && (
<img
src={`storage/images/${this.state.savedImage}`}
className="img-fluid img-thumbnail"
width={150}
/>
)}
{errors.file && (
<div className="invalid-feedback">
{errors.image}
</div>
)}
</div>
Full code is shown as below:
import React, { Component } from "react";
import axios from "axios";
import { withRouter } from "react-router-dom";
class CreateUpdatePage extends Component {
constructor(props) {
super(props);
this.state = {
title: "",
category: "",
content: "",
savedImage: "",
image: "",
errors: {},
isEdit:
props.match.params.id &&
props.location.pathname.includes("edit"),
};
}
componentDidMount() {
if (this.state.isEdit) {
this.fetchPostData();
}
}
fetchPostData = () => {
const { match } = this.props;
axios
.get(`/api/posts/${match.params.id}`)
.then((response) => {
const { title, category, content, image } = response.data.post;
this.setState({
title: title,
category: category,
content: content,
savedImage: image,
});
})
.catch((error) => {
console.error("Error fetching post data:", error);
});
};
handleInputChange = (e) => {
// e.persist();
const { name, value } = e.target;
this.setState({ [name]: value });
};
handleFileChange = (e) => {
this.setState({ image: e.target.files[0] });
};
handleSubmit = (e) => {
e.preventDefault();
const { title, category, content, image, isEdit } = this.state;
const formData = new FormData();
formData.set("title", title);
formData.set("category", category);
formData.set("content", content);
if (typeof image !== "string") {
formData.set("image", image);
} else {
formData.set("image", "");
}
let params = {
title: title,
category: category,
content: content,
};
const { match, history } = this.props;
const apiUrl = isEdit ? `/api/posts/${match.params.id}` : "/api/posts";
if (isEdit) {
formData.set("_method", "PUT");
axios
.post(apiUrl, formData)
.then((response) => {
console.log(
"Post created/updated successfully:",
response.data
);
// After a successful post creation/update, navigate to the /posts page
history.push("/posts");
})
.catch((error) => {
if (error.response && error.response.data) {
this.setState({
errors: error.response.data.errors,
});
}
console.error("Error creating/updating post:", error);
});
} else {
axios
.post(apiUrl, formData)
.then((response) => {
console.log(
"Post created/updated successfully:",
response.data
);
// After a successful post creation/update, navigate to the /posts page
history.push("/posts");
})
.catch((error) => {
if (error.response && error.response.data) {
this.setState({
errors: error.response.data.errors,
});
}
console.error("Error creating/updating post:", error);
});
}
};
render() {
const { errors, isEdit } = this.state;
const buttonText = isEdit ? "Update Post" : "Add Post";
return (
<div className="row my-3">
<div className="col-lg-8 mx-auto">
<div className="card shadow">
<div className="card-header text-white bg-primary fw-bold fs-3">
{isEdit ? "Update Post" : "Add New Post"}
</div>
<div className="card-body p-4">
<form
onSubmit={this.handleSubmit}
encType="multipart/form-data"
>
<div className="my-2">
<input
type="text"
name="title"
value={this.state.title}
onChange={this.handleInputChange}
className={`form-control ${
errors.title ? "is-invalid" : ""
}`}
placeholder="Title"
/>
{errors.title && (
<div className="invalid-feedback">
{errors.title}
</div>
)}
</div>
<div className="my-2">
<input
type="text"
name="category"
value={this.state.category}
onChange={this.handleInputChange}
className={`form-control ${
errors.category ? "is-invalid" : ""
}`}
placeholder="Category"
/>
{errors.category && (
<div className="invalid-feedback">
{errors.category}
</div>
)}
</div>
<div className="my-2">
<input
type="file"
name="image"
onChange={this.handleFileChange}
className={`form-control ${
errors.image ? "is-invalid" : ""
}`}
/>
{isEdit && (
<img
src={`storage/images/${this.state.savedImage}`}
className="img-fluid img-thumbnail"
width={150}
/>
)}
{errors.file && (
<div className="invalid-feedback">
{errors.image}
</div>
)}
</div>
<div className="my-2">
<textarea
name="content"
value={this.state.content}
onChange={this.handleInputChange}
rows="6"
className={`form-control ${
errors.content ? "is-invalid" : ""
}`}
placeholder="Post Content"
/>
{errors.content && (
<div className="invalid-feedback">
{errors.content}
</div>
)}
</div>
<div className="my-2">
<input
type="submit"
value={buttonText}
className={`btn ${
isEdit
? "btn-success"
: "btn-primary"
}`}
/>
</div>
</form>
</div>
</div>
</div>
</div>
);
}
}
export default withRouter(CreateUpdatePage);
There are 2 method need to be updated since we added image to out blog post.
Full code is shown as below:
public function store(Request $request)
{
$validator = Validator::make($request->all(),[
'title' => 'required|string|max:255',
'category' => 'required|string|max:255',
'content' => 'required|string',
'image' => 'required|image|mimes:jpeg,png,jpg,gif,svg|max:5000',
]);
if ($validator->fails()){
return response()->json(['errors'=>$validator->errors()], 422);
}
$post = new Post();
$post->title = $request->title;
$post->category = $request->category;
$post->content = $request->content;
if ($request->hasFile('image')) {
$imageName = time() . '.' . $request->file('image')->getClientOriginalExtension();
$request->file('image')->storeAs('public/images', $imageName);
$post->image = $imageName;
}
$post->save();
// Return a JSON response indicating success
return response()->json(['message' => 'Post added successfully', 'status' => 'success']);
}
null
.Full code is shown as below:
public function update(Request $request, $id)
{
$post = Post::find($id);
$validator = Validator::make($request->all(),[
'title' => 'required|string|max:255',
'category' => 'required|string|max:255',
'content' => 'required|string',
'image' => 'sometimes|nullable|image|mimes:jpeg,png,jpg,gif,svg|max:5000',
]);
if ($validator->fails()){
return response()->json(['errors'=>$validator->errors()], 422);
}
if ($request->hasFile('image')) {
$imageName = time() . '.' . $request->file('image')->getClientOriginalExtension();
$request->file('image')->storeAs('public/images', $imageName);
if ($post->image) {
Storage::delete('public/images/' . $post->image);
}
$post->image = $imageName;
}
// Update the post data
$post->title = $request->title;
$post->category = $request->category;
$post->content = $request->content;
// Save the updated post
$post->save();
return response()->json(['message' => 'Post updated successfully'], 200);
}
Before we creating a new a new post, we have to store all the images in the storage directory so to access those images from the publicdirectory we have to run a simple command:
php artisan storage:link
We just learn on how to add image to our post blog app with FormData()
and adding icon via bootstrap-icons
to our blog post app. We also learn on how to handle storing and deleting image data on our backend via laravel.
If you have any questions or concerns, please don’t hesitate to leave a comment below.
Laravel Official
Creating CRUD App with React & Laravel 8
Bootstrap Icons