feat: Implement news listing page with search, filter, and pagination, and update news table schema.

This commit is contained in:
Yoga Pangestu 2025-12-24 14:50:26 +07:00
parent 2eaed7bb27
commit 861aff3c78
10 changed files with 606 additions and 3 deletions

View File

@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Home;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\News;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Inertia\Inertia;
class NewsController extends Controller
{
public function index(Request $request)
{
$search = $request->get('search');
$category = $request->get('category');
$news = News::with(['author', 'categories'])
->published()
->when($search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%")
->orWhere('excerpt', 'like', "%{$search}%");
});
})
->when($category, function ($query, $category) {
if ($category !== 'all') {
$query->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category);
});
}
})
->orderBy('published_at', 'desc')
->paginate(12)
->withQueryString()
->through(function ($news) {
$news->thumbnail = $news->getFirstMediaUrl('news');
$news->formatted_published_at = Carbon::parse($news->published_at)
->locale('id')
->translatedFormat('l, d F Y');
return $news;
});
$mostViewedNews = News::with(['author', 'categories'])
->published()
->orderBy('views', 'desc')
->take(4)
->get()
->map(function ($news) {
$news->thumbnail = $news->getFirstMediaUrl('news');
$news->formatted_published_at = Carbon::parse($news->published_at)
->locale('id')
->translatedFormat('l, d F Y');
return $news;
});
return Inertia::render('home/news/pages/Index', [
'pageTitle' => 'Berita',
'pageDescription' => 'Dapatkan informasi terbaru, artikel, dan update berita terkini tentang Purwakarta.',
'categories' => Category::active()->get(),
'news' => $news,
'mostViewedNews' => $mostViewedNews,
'filters' => request()->only(['search', 'category']),
]);
}
}

View File

@ -21,7 +21,7 @@ public function up(): void
$table->text('content');
$table->text('excerpt');
$table->string('link', 50)->nullable();
$table->unsignedInteger('view')->default(0);
$table->unsignedInteger('views')->default(0);
$table->enum('status', [NewsStatus::values()])->comment(NewsStatus::comment());
$table->timestamp('published_at')->nullable();
$table->timestamp('created_at')->useCurrent();

View File

@ -0,0 +1,43 @@
import Container from '@mui/material/Container';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
export default function Headline({ pageTitle, pageDescription }) {
return (
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Stack
spacing={2}
useFlexGap
sx={{ alignItems: 'center', width: { xs: '100%', sm: '70%' } }}
>
<Typography
variant="h1"
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: 'center',
fontSize: 'clamp(3rem, 10vw, 3.5rem)',
}}
>
{pageTitle}
</Typography>
<Typography
sx={{
textAlign: 'center',
color: 'text.secondary',
width: { sm: '100%', md: '80%' },
}}
>
{pageDescription}
</Typography>
</Stack>
</Container>
);
}

View File

@ -13,6 +13,7 @@ import MenuIcon from '@mui/icons-material/Menu';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ColorModeIconDropdown from '../../theme/ColorModeIconDropdown';
import Logo from './Logo';
import { router } from '@inertiajs/react';
const StyledToolbar = styled(Toolbar)(({ theme }) => ({
display: 'flex',
@ -53,8 +54,13 @@ export default function Navbar() {
<Box sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', px: 0 }}>
<Logo />
<Box sx={{ display: { xs: 'none', md: 'flex' } }}>
<Button variant="text" color="info" size="small">
Features
<Button
variant="text"
color="info"
size="small"
onClick={() => router.get('/news')}
>
Berita
</Button>
<Button variant="text" color="info" size="small">
Testimonials

View File

@ -0,0 +1,33 @@
import { Avatar, Box, Typography } from "@mui/material";
export default function Author({ author, publishedAt }) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
}}
>
<Box
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}
>
<Avatar
alt={author}
sx={{ width: 24, height: 24 }}
>
{author.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="caption">
{author}
</Typography>
</Box>
<Typography variant="caption">
{publishedAt}
</Typography>
</Box>
);
}

View File

@ -0,0 +1,272 @@
import { router } from '@inertiajs/react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Chip from '@mui/material/Chip';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
import Headline from '../../components/Headline';
import { Pagination } from '@mui/material';
import { useState, useEffect, useRef } from "react";
import Author from './Author';
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
import { Search } from './Search';
const StyledCard = styled(Card)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
padding: 0,
height: '100%',
backgroundColor: (theme.vars || theme).palette.background.paper,
'&:hover': {
backgroundColor: 'transparent',
cursor: 'pointer',
},
'&:focus-visible': {
outline: '3px solid',
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
outlineOffset: '2px',
},
}));
const StyledCardContent = styled(CardContent)({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: 16,
flexGrow: 1,
'&:last-child': {
paddingBottom: 16,
},
});
const StyledTypography = styled(Typography)({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export default function MainContent({ pageTitle, pageDescription, categories, news, filters }) {
const [selectedCategory, setSelectedCategory] = useState(filters?.category || 'all');
const [search, setSearch] = useState(filters?.search || '');
const [focusedCardIndex, setFocusedCardIndex] = useState(null);
const searchDebounceRef = useRef(null);
const handleFocus = (index) => {
setFocusedCardIndex(index);
};
useEffect(() => {
if (search === (filters?.search || '')) {
return;
}
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
searchDebounceRef.current = setTimeout(() => {
router.get(window.location.pathname, {
category: selectedCategory,
search: search
}, {
preserveState: true,
preserveScroll: true,
replace: true,
});
}, 500);
return () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
};
}, [search]);
const handleBlur = () => {
setFocusedCardIndex(null);
};
const handleCategory = (category) => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
setSelectedCategory(category);
router.get(window.location.pathname, {
category: category,
search: search
}, {
preserveState: true,
preserveScroll: true
});
};
const handleSearch = () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
router.get(window.location.pathname, {
category: selectedCategory,
search: search
}, {
preserveState: true,
preserveScroll: true
});
};
const handlePageChange = (event, value) => {
router.get(window.location.pathname, {
page: value,
category: selectedCategory,
search: search
}, {
preserveState: true,
preserveScroll: true,
});
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div>
<Headline pageTitle={pageTitle} pageDescription={pageDescription} />
</div>
<Box
sx={{
display: { xs: 'flex', sm: 'none' },
flexDirection: 'row',
gap: 1,
width: { xs: '100%', md: 'fit-content' },
overflow: 'auto',
}}
>
<Search value={search} onChange={(e) => setSearch(e.target.value)} onSearch={handleSearch} />
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column-reverse', md: 'row' },
width: '100%',
justifyContent: 'space-between',
alignItems: { xs: 'start', md: 'center' },
gap: 4,
overflow: 'auto',
}}
>
<Box
sx={{
display: 'inline-flex',
flexDirection: 'row',
gap: 1,
overflow: 'auto',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
}}
>
<Chip
onClick={() => handleCategory('all')}
size="medium"
label="Semua"
sx={{
backgroundColor: selectedCategory === 'all' ? '' : 'transparent',
}}
/>
{categories.map((category, index) =>
<Chip
onClick={() => handleCategory(category.id)}
size="medium"
label={category.name}
key={index}
sx={{
backgroundColor: selectedCategory === category.id ? '' : 'transparent',
}}
/>
)}
</Box>
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
flexDirection: 'row',
gap: 1,
width: { xs: '100%', md: 'fit-content' },
overflow: 'auto',
}}
>
<Search value={search} onChange={(e) => setSearch(e.target.value)} onSearch={handleSearch} />
</Box>
</Box>
<Grid container spacing={2} columns={12}>
{news.data.map((news, index) =>
<Grid size={{ xs: 12, md: 6 }} key={index}>
<StyledCard
variant="outlined"
onFocus={() => handleFocus(news.id)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === news.id ? 'Mui-focused' : ''}
>
<CardMedia
component="img"
alt={news.title}
image={news.thumbnail}
sx={{
aspectRatio: '16 / 9',
borderBottom: '1px solid',
borderColor: 'divider',
}}
/>
<StyledCardContent>
<Typography gutterBottom variant="caption" component="div">
{news.categories.map((category, index) =>
<Chip label={category.name} color="success" variant="outlined" sx={{ mr: 1 }} key={index} />
)}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{news.title}
</Typography>
<StyledTypography
variant="body2"
color="text.secondary"
gutterBottom
dangerouslySetInnerHTML={{ __html: news.excerpt }}
/>
</StyledCardContent>
<Author author={news.author?.name} publishedAt={news.formatted_published_at} />
</StyledCard>
</Grid>
)}
</Grid>
{news.data.length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'row', py: 4 }}>
<Pagination
count={news.last_page}
showFirstButton
showLastButton
page={news.current_page}
onChange={handlePageChange}
/>
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
<DotLottieReact
src="/assets/lottie/cat-fishing.lottie"
loop
autoplay
style={{ width: '240px', height: '240px' }}
/>
<Typography gutterBottom variant="body1" component="div">
Tidak ada berita yang ditemukan
</Typography>
</Box>
)
}
</Box >
);
}

View File

@ -0,0 +1,114 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
import { Chip } from '@mui/material';
import Author from './Author';
const StyledTypography = styled(Typography)({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
});
const TitleTypography = styled(Typography)(({ theme }) => ({
position: 'relative',
textDecoration: 'none',
'&:hover': { cursor: 'pointer' },
'& .arrow': {
visibility: 'hidden',
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
},
'&:hover .arrow': {
visibility: 'visible',
opacity: 0.7,
},
'&:focus-visible': {
outline: '3px solid',
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
outlineOffset: '3px',
borderRadius: '8px',
},
'&::before': {
content: '""',
position: 'absolute',
width: 0,
height: '1px',
bottom: 0,
left: 0,
backgroundColor: (theme.vars || theme).palette.text.primary,
opacity: 0.3,
transition: 'width 0.3s ease, opacity 0.3s ease',
},
'&:hover::before': {
width: '100%',
},
}));
export default function MostViewed({ mostViewedNews }) {
const [focusedCardIndex, setFocusedCardIndex] = React.useState(null);
const handleFocus = (index) => {
setFocusedCardIndex(index);
};
const handleBlur = () => {
setFocusedCardIndex(null);
};
return (
<div>
<Typography variant="h2" gutterBottom>
Paling Banyak Dilihat
</Typography>
<Grid container spacing={8} columns={12} sx={{ my: 4 }}>
{mostViewedNews.map((news, index) => (
<Grid key={index} size={{ xs: 12, sm: 6 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 1,
height: '100%',
}}
>
<Typography gutterBottom variant="caption" component="div">
{news.categories.map((category, index) =>
<Chip label={category.name} color="success" variant="outlined" sx={{ mr: 1 }} key={index} />
)}
</Typography>
<TitleTypography
gutterBottom
variant="h6"
onFocus={() => handleFocus(index)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === index ? 'Mui-focused' : ''}
>
{news.title}
<NavigateNextRoundedIcon
className="arrow"
sx={{ fontSize: '1rem' }}
/>
</TitleTypography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{news.description}
</StyledTypography>
<Author author={news.author?.name} publishedAt={news.formatted_published_at} />
</Box>
</Grid>
))}
</Grid>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { FormControl, InputAdornment, OutlinedInput } from "@mui/material";
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export function Search({ value, onChange, onSearch }) {
return (
<FormControl sx={{ width: { xs: '100%', md: '100ch' } }} variant="outlined">
<OutlinedInput
autoComplete='off'
size="small"
id="search"
placeholder="Cari berita..."
sx={{ flexGrow: 1 }}
value={value}
onChange={onChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onSearch();
}
}}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{
'aria-label': 'search',
}}
/>
</FormControl>
);
}

View File

@ -0,0 +1,32 @@
import CssBaseline from '@mui/material/CssBaseline';
import Container from '@mui/material/Container';
import AppTheme from '../../../theme/AppTheme';
import Navbar from '../../components/Navbar';
import MainContent from '../components/MainContent';
import MostViewed from '../components/MostViewed';
import Footer from '../../components/Footer';
export default function NewsIndexPage(props) {
return (
<AppTheme {...props}>
<CssBaseline enableColorScheme />
<Navbar />
<Container
maxWidth="lg"
component="main"
sx={{ display: 'flex', flexDirection: 'column', my: 16, gap: 4 }}
>
<MainContent
pageTitle={props.pageTitle}
pageDescription={props.pageDescription}
categories={props.categories}
news={props.news}
filters={props.filters}
/>
<MostViewed mostViewedNews={props.mostViewedNews} />
</Container>
<Footer />
</AppTheme>
);
}

View File

@ -1,6 +1,8 @@
<?php
use App\Http\Controllers\Home\HomepageController;
use App\Http\Controllers\Home\NewsController;
use Illuminate\Support\Facades\Route;
Route::get('/', [HomepageController::class, 'index'])->name('homepage');
Route::get('/news', [NewsController::class, 'index'])->name('news');