Compare commits
10 Commits
0.0.5
...
42fe31fc69
| Author | SHA1 | Date | |
|---|---|---|---|
| 42fe31fc69 | |||
| 4f442c369b | |||
| 6b8d351fed | |||
| fd5093a1f8 | |||
| d3acf05b08 | |||
| bc6bfef6ea | |||
| eedb9a24f3 | |||
| 998c3d490d | |||
| bb3f733ffc | |||
| ce7b5dab6b |
14
.drone.yml
14
.drone.yml
@@ -63,6 +63,9 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: docker:24
|
image: docker:24
|
||||||
|
environment:
|
||||||
|
API_BASE_URL:
|
||||||
|
from_secret: API_BASE_URL
|
||||||
volumes:
|
volumes:
|
||||||
- name: dockersock
|
- name: dockersock
|
||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
@@ -70,7 +73,12 @@ steps:
|
|||||||
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
- echo "🔨 Building Docker image apps/blog:$IMAGE_TAG ..."
|
- echo "🔨 Building Docker image apps/blog:$IMAGE_TAG ..."
|
||||||
- docker build --network=host -t apps/blog:$IMAGE_TAG -t apps/blog:latest /drone/src
|
- |
|
||||||
|
docker build --network=host \
|
||||||
|
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
||||||
|
-t apps/blog:$IMAGE_TAG \
|
||||||
|
-t apps/blog:latest \
|
||||||
|
/drone/src
|
||||||
|
|
||||||
- name: push-image
|
- name: push-image
|
||||||
image: docker:24
|
image: docker:24
|
||||||
@@ -108,9 +116,6 @@ steps:
|
|||||||
|
|
||||||
- name: run-container
|
- name: run-container
|
||||||
image: docker:24
|
image: docker:24
|
||||||
environment:
|
|
||||||
API_BASE_URL:
|
|
||||||
from_secret: API_BASE_URL
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: dockersock
|
- name: dockersock
|
||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
@@ -123,7 +128,6 @@ steps:
|
|||||||
--name blog \
|
--name blog \
|
||||||
-p 3002:3000 \
|
-p 3002:3000 \
|
||||||
-e NODE_ENV=production \
|
-e NODE_ENV=production \
|
||||||
-e VITE_API_BASE_URL="$API_BASE_URL" \
|
|
||||||
--restart always \
|
--restart always \
|
||||||
apps/blog:$IMAGE_TAG
|
apps/blog:$IMAGE_TAG
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
RUN npm run build
|
ARG VITE_API_BASE_URL
|
||||||
|
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL npm run build
|
||||||
|
|
||||||
# Stage 2: Static file server (BusyBox)
|
# Stage 2: Static file server (BusyBox)
|
||||||
FROM busybox:latest
|
FROM busybox:latest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.0.4",
|
"version": "0.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
import AppTheme from '../shared-theme/AppTheme';
|
import AppTheme from '../shared-theme/AppTheme';
|
||||||
import MainContent from './components/MainContent';
|
import MainContent from './components/MainContent';
|
||||||
import Article from './components/Article';
|
import Article from './components/Article';
|
||||||
import Latest from './components/Latest';
|
import Latest from './components/Latest';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import { useArticles } from './providers/Article'; // ✅ custom hook for global articles
|
import { useArticles } from './providers/Article';
|
||||||
|
|
||||||
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||||
const { articles, loading, error } = useArticles(); // ✅ Hook must be inside component
|
const { articles, loading, error } = useArticles();
|
||||||
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
||||||
|
|
||||||
const handleSelectArticle = (index: number) => {
|
const handleSelectArticle = (index: number) => {
|
||||||
@@ -26,7 +27,12 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
component="main"
|
component="main"
|
||||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Loading articles...
|
Loading articles...
|
||||||
</Container>
|
</Container>
|
||||||
@@ -41,7 +47,12 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
component="main"
|
component="main"
|
||||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Failed to load articles: {error}
|
Failed to load articles: {error}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -53,21 +64,61 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
<AppTheme {...props}>
|
<AppTheme {...props}>
|
||||||
<CssBaseline enableColorScheme />
|
<CssBaseline enableColorScheme />
|
||||||
|
|
||||||
<Container
|
<Box
|
||||||
maxWidth="lg"
|
sx={{
|
||||||
component="main"
|
display: 'flex',
|
||||||
sx={{ display: 'flex', flexDirection: 'column', my: 16, gap: 4 }}
|
flexDirection: 'column',
|
||||||
|
minHeight: '100vh',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selectedArticle === null ? (
|
<Container
|
||||||
<>
|
maxWidth="lg"
|
||||||
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
component="main"
|
||||||
<Latest articles={articles.slice(0, 3)} /> {/* show 3 most recent */}
|
sx={{
|
||||||
</>
|
flex: '1 0 auto',
|
||||||
) : (
|
display: 'flex',
|
||||||
<Article article={articles[selectedArticle]} onBack={handleBack} />
|
flexDirection: 'column',
|
||||||
|
my: 4,
|
||||||
|
gap: 4,
|
||||||
|
pb: selectedArticle === null ? 24 : 0, // space for fixed footer on home
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedArticle === null ? (
|
||||||
|
<>
|
||||||
|
<MainContent
|
||||||
|
articles={articles}
|
||||||
|
onSelectArticle={handleSelectArticle}
|
||||||
|
/>
|
||||||
|
<Latest
|
||||||
|
articles={articles}
|
||||||
|
onSelectArticle={handleSelectArticle}
|
||||||
|
onLoadMore={async (offset, limit) => {
|
||||||
|
// Optional pagination call
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Article article={articles[selectedArticle]} onBack={handleBack} />
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{selectedArticle === null && (
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: '0 -2px 10px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Footer />
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Box>
|
||||||
<Footer />
|
|
||||||
</AppTheme>
|
</AppTheme>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { marked } from 'marked';
|
|||||||
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
|
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import { ArticleProps } from '../types/props';
|
||||||
|
|
||||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
@@ -23,11 +24,9 @@ const CoverImage = styled('img')({
|
|||||||
|
|
||||||
export default function Article({
|
export default function Article({
|
||||||
article,
|
article,
|
||||||
onBack,
|
onBack
|
||||||
}: {
|
}: ArticleProps) {
|
||||||
article: any;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<ArticleContainer>
|
<ArticleContainer>
|
||||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||||
@@ -44,16 +43,19 @@ export default function Article({
|
|||||||
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
||||||
{article.title}
|
{article.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" color="text.secondary" gutterBottom>
|
|
||||||
{article.subtitle}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
|
||||||
<Avatar src={article.authors[0].avatar} alt={article.authors[0].name} />
|
<Avatar src={article.authors[0].avatar} alt={article.authors[0].name} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2">{article.authors[0].name}</Typography>
|
<Typography variant="subtitle2">{article.authors[0].name}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{article.authors[0].date}
|
{new Date(article.created_at).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
import Divider from '@mui/material/Divider';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
|
||||||
import Link from '@mui/material/Link';
|
import Link from '@mui/material/Link';
|
||||||
import Stack from '@mui/material/Stack';
|
|
||||||
import TextField from '@mui/material/TextField';
|
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
|
||||||
import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
|
||||||
import TwitterIcon from '@mui/icons-material/X';
|
|
||||||
|
|
||||||
function Copyright() {
|
function Copyright() {
|
||||||
return (
|
return (
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{'Copyright © '}
|
<Link color="text.secondary" href="https://www.aetoskia.com/">
|
||||||
<Link color="text.secondary" href="https://mui.com/">
|
{'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
|
||||||
Sitemark
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{new Date().getFullYear()}
|
{new Date().getFullYear()}
|
||||||
@@ -29,197 +19,17 @@ function Copyright() {
|
|||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Divider />
|
|
||||||
<Container
|
<Container
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: { xs: 4, sm: 8 },
|
gap: { xs: 2, sm: 4 },
|
||||||
py: { xs: 8, sm: 10 },
|
py: { xs: 2, sm: 4 },
|
||||||
textAlign: { sm: 'center', md: 'left' },
|
textAlign: { sm: 'center', md: 'left' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Copyright />
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 4,
|
|
||||||
minWidth: { xs: '100%', sm: '60%' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ width: { xs: '100%', sm: '60%' } }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
gutterBottom
|
|
||||||
sx={{ fontWeight: 600, mt: 2 }}
|
|
||||||
>
|
|
||||||
Join the newsletter
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
|
||||||
Subscribe for weekly updates. No spams ever!
|
|
||||||
</Typography>
|
|
||||||
<InputLabel htmlFor="email-newsletter">Email</InputLabel>
|
|
||||||
<Stack direction="row" spacing={1} useFlexGap>
|
|
||||||
<TextField
|
|
||||||
id="email-newsletter"
|
|
||||||
hiddenLabel
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
aria-label="Enter your email address"
|
|
||||||
placeholder="Your email address"
|
|
||||||
slotProps={{
|
|
||||||
htmlInput: {
|
|
||||||
autoComplete: 'off',
|
|
||||||
'aria-label': 'Enter your email address',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{ width: '250px' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: { xs: 'none', sm: 'flex' },
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
|
||||||
Product
|
|
||||||
</Typography>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Features
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Testimonials
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Highlights
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
FAQs
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: { xs: 'none', sm: 'flex' },
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
|
||||||
Company
|
|
||||||
</Typography>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
About us
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Careers
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Press
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: { xs: 'none', sm: 'flex' },
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
|
||||||
Legal
|
|
||||||
</Typography>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Terms
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Privacy
|
|
||||||
</Link>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Contact
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
pt: { xs: 4, sm: 8 },
|
|
||||||
width: '100%',
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
<Typography sx={{ display: 'inline', mx: 0.5, opacity: 0.5 }}>
|
|
||||||
•
|
|
||||||
</Typography>
|
|
||||||
<Link color="text.secondary" variant="body2" href="#">
|
|
||||||
Terms of Service
|
|
||||||
</Link>
|
|
||||||
<Copyright />
|
|
||||||
</div>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={1}
|
|
||||||
useFlexGap
|
|
||||||
sx={{ justifyContent: 'left', color: 'text.secondary' }}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
href="https://github.com/mui"
|
|
||||||
aria-label="GitHub"
|
|
||||||
sx={{ alignSelf: 'center' }}
|
|
||||||
>
|
|
||||||
<GitHubIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
href="https://x.com/MaterialUI"
|
|
||||||
aria-label="X"
|
|
||||||
sx={{ alignSelf: 'center' }}
|
|
||||||
>
|
|
||||||
<TwitterIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
href="https://www.linkedin.com/company/mui/"
|
|
||||||
aria-label="LinkedIn"
|
|
||||||
sx={{ alignSelf: 'center' }}
|
|
||||||
>
|
|
||||||
<LinkedInIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
</Container>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import Avatar from '@mui/material/Avatar';
|
|||||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Pagination from '@mui/material/Pagination';
|
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
||||||
import type { Article } from '../providers/Article'; // ✅ import type for correctness
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import { LatestProps } from "../types/props";
|
||||||
|
import Fade from '@mui/material/Fade'; // ✅ for smooth appearance
|
||||||
|
|
||||||
|
|
||||||
const StyledTypography = styled(Typography)({
|
const StyledTypography = styled(Typography)({
|
||||||
@@ -88,69 +89,118 @@ function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Latest component ---- //
|
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
|
||||||
interface LatestProps {
|
const [visibleCount, setVisibleCount] = React.useState(2);
|
||||||
articles: Article[];
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
onSelectArticle?: (index: number) => void;
|
const [animating, setAnimating] = React.useState(false);
|
||||||
}
|
const loaderRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
export default function Latest({ articles, onSelectArticle }: LatestProps) {
|
const displayedArticles = articles.slice(0, visibleCount);
|
||||||
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleFocus = (index: number) => setFocusedCardIndex(index);
|
React.useEffect(() => {
|
||||||
const handleBlur = () => setFocusedCardIndex(null);
|
if (!loaderRef.current) return;
|
||||||
|
|
||||||
// limit to 4-6 items for visual balance
|
const observer = new IntersectionObserver(
|
||||||
const displayedArticles = articles.slice(0, 6);
|
async (entries) => {
|
||||||
|
const first = entries[0];
|
||||||
|
if (first.isIntersecting && !loadingMore && visibleCount < articles.length) {
|
||||||
|
console.log('🟡 Intersection triggered — loading more blogs...');
|
||||||
|
setLoadingMore(true);
|
||||||
|
|
||||||
|
// simulate API load delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
if (onLoadMore) {
|
||||||
|
console.log(`📡 Calling onLoadMore(offset=${visibleCount}, limit=2)`);
|
||||||
|
await onLoadMore(visibleCount, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnimating(true);
|
||||||
|
setVisibleCount((prev) => {
|
||||||
|
const newCount = prev + 2;
|
||||||
|
console.log(`✅ Increasing visibleCount from ${prev} → ${newCount}`);
|
||||||
|
return newCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => setAnimating(false), 600);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const current = loaderRef.current;
|
||||||
|
observer.observe(current);
|
||||||
|
|
||||||
|
console.log('👀 IntersectionObserver attached to loaderRef:', loaderRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (current) observer.unobserve(current);
|
||||||
|
console.log('🧹 IntersectionObserver detached');
|
||||||
|
};
|
||||||
|
}, [loadingMore, visibleCount, articles.length, onLoadMore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Box>
|
||||||
<Typography variant="h2" gutterBottom>
|
<Typography variant="h2" gutterBottom>
|
||||||
Latest
|
Latest
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={8} columns={12} sx={{ my: 4 }}>
|
<Grid container spacing={8} columns={12} sx={{ my: 4 }}>
|
||||||
{displayedArticles.map((article, index) => (
|
{displayedArticles.map((article, index) => (
|
||||||
<Grid key={index} size={{ xs: 12, sm: 6 }}>
|
<Grid key={index} size={{ xs: 12, sm: 6 }}>
|
||||||
<Box
|
<Fade in timeout={animating ? 700 : 0}>
|
||||||
sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
flexDirection: 'column',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
flexDirection: 'column',
|
||||||
gap: 1,
|
justifyContent: 'space-between',
|
||||||
height: '100%',
|
gap: 1,
|
||||||
}}
|
height: '100%',
|
||||||
>
|
transition: 'transform 0.3s ease',
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
'&:hover': { transform: 'translateY(-3px)' },
|
||||||
{article.tag}
|
}}
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<TitleTypography
|
|
||||||
gutterBottom
|
|
||||||
variant="h6"
|
|
||||||
tabIndex={0}
|
|
||||||
onFocus={() => handleFocus(index)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onClick={() => onSelectArticle?.(index)}
|
|
||||||
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
|
||||||
>
|
>
|
||||||
{article.title}
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
<NavigateNextRoundedIcon
|
{article.tag}
|
||||||
className="arrow"
|
</Typography>
|
||||||
sx={{ fontSize: '1rem' }}
|
|
||||||
/>
|
|
||||||
</TitleTypography>
|
|
||||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
{article.description}
|
|
||||||
</StyledTypography>
|
|
||||||
|
|
||||||
<Author authors={article.authors} />
|
<TitleTypography
|
||||||
</Box>
|
gutterBottom
|
||||||
|
variant="h6"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSelectArticle?.(index)}
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
<NavigateNextRoundedIcon className="arrow" sx={{ fontSize: '1rem' }} />
|
||||||
|
</TitleTypography>
|
||||||
|
|
||||||
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
|
||||||
|
<Author authors={article.authors} />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 4 }}>
|
|
||||||
<Pagination hidePrevButton hideNextButton count={10} boundaryCount={10} />
|
<Box
|
||||||
|
ref={loaderRef}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
py: 3,
|
||||||
|
opacity: loadingMore ? 1 : 0.6,
|
||||||
|
transition: 'opacity 0.4s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<CircularProgress size={32} thickness={5} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption">Scroll to load more...</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { styled } from '@mui/material/styles';
|
|||||||
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
||||||
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
|
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
|
||||||
|
|
||||||
import { useArticles } from '../providers/Article';
|
|
||||||
|
|
||||||
const StyledCard = styled(Card)(({ theme }) => ({
|
const StyledCard = styled(Card)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -138,7 +137,6 @@ export default function MainContent({
|
|||||||
<Typography variant="h1" gutterBottom>
|
<Typography variant="h1" gutterBottom>
|
||||||
Blog
|
Blog
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography>Stay in the loop with the latest about our products</Typography>
|
|
||||||
</div>
|
</div>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -1,42 +1,14 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { ArticleModel } from "../types/models";
|
||||||
|
import { ArticleContextModel } from "../types/contexts";
|
||||||
|
|
||||||
interface Author {
|
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
|
||||||
_id?: string | null;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Article {
|
|
||||||
_id?: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
img: string;
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
authors: Author[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArticleContextType {
|
|
||||||
articles: Article[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refreshArticles: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ArticleContext = createContext<ArticleContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -46,7 +18,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// ✅ Use correct full endpoint from OpenAPI spec
|
// ✅ Use correct full endpoint from OpenAPI spec
|
||||||
const res = await axios.get<Article[]>(`${API_BASE}/articles`, {
|
const res = await axios.get<ArticleModel[]>(`${API_BASE}/articles`, {
|
||||||
params: { skip: 0, limit: 10 },
|
params: { skip: 0, limit: 10 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +48,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useArticles = (): ArticleContextType => {
|
export const useArticles = (): ArticleContextModel => {
|
||||||
const ctx = useContext(ArticleContext);
|
const ctx = useContext(ArticleContext);
|
||||||
if (!ctx) throw new Error('useArticles must be used inside ArticleProvider');
|
if (!ctx) throw new Error('useArticles must be used inside ArticleProvider');
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|||||||
107
src/blog/providers/Author.tsx
Normal file
107
src/blog/providers/Author.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { AuthorModel } from "../types/models";
|
||||||
|
import { AuthContextModel } from "../types/contexts";
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [currentUser, setCurrentUser] = useState<AuthorModel | null>(null);
|
||||||
|
const [authors, setAuthors] = useState<AuthorModel[]>([]);
|
||||||
|
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** 🔹 Login and store JWT token */
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await axios.post(`${API_BASE}/auth/login`, { email, password });
|
||||||
|
const { access_token, user } = res.data;
|
||||||
|
|
||||||
|
if (access_token) {
|
||||||
|
localStorage.setItem('token', access_token);
|
||||||
|
setToken(access_token);
|
||||||
|
setCurrentUser(user);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Login failed:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Invalid credentials');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Logout and clear everything */
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
setCurrentUser(null);
|
||||||
|
setAuthors([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Fetch all authors (requires valid JWT) */
|
||||||
|
const refreshAuthors = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await axios.get<AuthorModel[]>(`${API_BASE}/authors`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuthors(res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch authors:', err);
|
||||||
|
setError(err.message || 'Failed to fetch authors');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Auto-load current user if token exists */
|
||||||
|
const fetchCurrentUser = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const res = await axios.get<AuthorModel>(`${API_BASE}/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setCurrentUser(res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch current user:', err);
|
||||||
|
logout(); // invalid/expired token
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) fetchCurrentUser();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
currentUser,
|
||||||
|
authors,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshAuthors,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextModel => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
19
src/blog/types/contexts.ts
Normal file
19
src/blog/types/contexts.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ArticleModel, AuthorModel } from "./models";
|
||||||
|
|
||||||
|
export interface ArticleContextModel {
|
||||||
|
articles: ArticleModel[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refreshArticles: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextModel {
|
||||||
|
currentUser: AuthorModel | null;
|
||||||
|
authors: AuthorModel[];
|
||||||
|
token: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshAuthors: () => Promise<void>;
|
||||||
|
}
|
||||||
30
src/blog/types/models.ts
Normal file
30
src/blog/types/models.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface AuthorModel {
|
||||||
|
// meta fields
|
||||||
|
_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
// model fields
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleModel {
|
||||||
|
// meta fields
|
||||||
|
_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
// model fields
|
||||||
|
img: string;
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
// ref fields
|
||||||
|
authors: AuthorModel[];
|
||||||
|
}
|
||||||
12
src/blog/types/props.ts
Normal file
12
src/blog/types/props.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ArticleModel } from "./models";
|
||||||
|
|
||||||
|
export interface LatestProps {
|
||||||
|
articles: ArticleModel[];
|
||||||
|
onSelectArticle?: (index: number) => void;
|
||||||
|
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user