23 Commits
0.0.5 ... 0.1.1

Author SHA1 Message Date
d29efe53e0 bumped up to 0.1.1
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-12 03:14:04 +05:30
089e5e1716 Merge branch 'jwt' 2025-11-12 03:13:47 +05:30
2374d9a437 bumped up to 0.1.0
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-11 20:54:58 +05:30
ef7ed61665 bumped up to 0.0.9 2025-11-11 20:54:30 +05:30
8a29261a3e profile and update view for author 2025-11-11 20:47:37 +05:30
89aa1c6ce4 cleanup code for view 2025-11-11 19:10:02 +05:30
557e8ddfc9 working login and register page 2025-11-11 18:56:48 +05:30
0267aedf52 register page 2025-11-11 18:48:06 +05:30
1c964a7fee login page 2025-11-11 18:47:59 +05:30
661f8c915b fixes for public listed articles 2025-11-11 18:47:49 +05:30
b2a7df5760 username and password instead of email and password 2025-11-11 18:47:16 +05:30
3bf0a5839c register function in Author contexts 2025-11-11 18:33:40 +05:30
90e6a85fff jwt provider and common api utils 2025-11-11 15:45:24 +05:30
42fe31fc69 refactor(types): centralize all interfaces into dedicated type models and update context usage
- Moved all interface definitions into

- Updated all providers and components to import interfaces from types/ folder

- Renamed interfaces for clarity

- Updated Article component to use typed props interface

- Added descriptive inline date formatting utility examples
2025-11-11 15:35:28 +05:30
4f442c369b feat(ui): make footer always visible on home and hidden in article view
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-09 00:00:55 +05:30
6b8d351fed bumping up to 0.0.8
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-07 21:49:45 +05:30
fd5093a1f8 smooth scrolling with fade 2025-11-07 21:48:01 +05:30
d3acf05b08 reduced my to 4 from 16 2025-11-07 21:43:27 +05:30
bc6bfef6ea cleanup 2025-11-07 21:43:15 +05:30
eedb9a24f3 bumping up to 0.0.7
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-07 21:34:14 +05:30
998c3d490d baking env in build 2025-11-07 21:33:44 +05:30
bb3f733ffc baking env in build 2025-11-07 21:32:31 +05:30
ce7b5dab6b infinity scrolling init 2025-11-07 21:27:29 +05:30
18 changed files with 935 additions and 334 deletions

View File

@@ -63,6 +63,9 @@ steps:
- name: build-image
image: docker:24
environment:
API_BASE_URL:
from_secret: API_BASE_URL
volumes:
- name: dockersock
path: /var/run/docker.sock
@@ -70,7 +73,12 @@ steps:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- 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
image: docker:24
@@ -108,9 +116,6 @@ steps:
- name: run-container
image: docker:24
environment:
API_BASE_URL:
from_secret: API_BASE_URL
volumes:
- name: dockersock
path: /var/run/docker.sock
@@ -123,7 +128,6 @@ steps:
--name blog \
-p 3002:3000 \
-e NODE_ENV=production \
-e VITE_API_BASE_URL="$API_BASE_URL" \
--restart always \
apps/blog:$IMAGE_TAG

View File

@@ -14,7 +14,8 @@ RUN npm ci
COPY . .
# 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)
FROM busybox:latest

View File

@@ -1,6 +1,6 @@
{
"name": "aetoskia-blog-app",
"version": "0.0.4",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -1,16 +1,29 @@
import * as React from 'react';
import CssBaseline from '@mui/material/CssBaseline';
import Container from '@mui/material/Container';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import AppTheme from '../shared-theme/AppTheme';
import MainContent from './components/MainContent';
import Article from './components/Article';
import Latest from './components/Latest';
import Footer from './components/Footer';
import { useArticles } from './providers/Article'; // ✅ custom hook for global articles
import Login from './components/Login';
import Register from './components/Register';
import Profile from './components/Profile';
import { useArticles } from './providers/Article';
import { useAuth } from './providers/Author';
type View = 'home' | 'login' | 'register' | 'article' | 'profile';
export default function Blog(props: { disableCustomTheme?: boolean }) {
const { articles, loading, error } = useArticles(); // ✅ Hook must be inside component
const { articles, loading, error } = useArticles();
const { currentUser } = useAuth();
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
const [showLogin, setShowLogin] = React.useState(false);
const [showRegister, setShowRegister] = React.useState(false);
const [showProfile, setShowProfile] = React.useState(false);
const handleSelectArticle = (index: number) => {
setSelectedArticle(index);
@@ -19,6 +32,99 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const handleBack = () => setSelectedArticle(null);
const handleShowLogin = () => {
setShowLogin(true);
setShowRegister(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleShowRegister = () => {
setShowRegister(true);
setShowLogin(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleHideAuth = () => {
setShowLogin(false);
setShowRegister(false);
};
const handleShowProfile = () => {
setShowProfile(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleHideProfile = () => {
setShowProfile(false);
};
// derive a single source of truth for view
const view: View = React.useMemo(() => {
if (selectedArticle !== null) return 'article';
if (showRegister) return 'register';
if (showLogin) return 'login';
if (showProfile) return 'profile';
return 'home';
}, [selectedArticle, showLogin, showRegister, showProfile]);
// render function keeps JSX tidy
const renderView = () => {
switch (view) {
case 'register':
return <Register onBack={handleHideAuth} />;
case 'login':
return (
<Login
onBack={handleHideAuth}
onRegister={() => {
handleShowRegister();
}}
/>
);
case 'profile':
return (
<Profile
onBack={handleHideProfile}
/>
);
case 'article':
if (selectedArticle == null || !articles[selectedArticle]) return null;
return <Article article={articles[selectedArticle]} onBack={handleBack} />;
case 'home':
default:
return (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
{!currentUser ? (
<>
<Button
variant="outlined"
color="primary"
onClick={handleShowLogin}
>
Login
</Button>
</>
) : (
<>
<Button
variant="outlined"
color="primary"
onClick={() => setShowProfile(true)}
>
Profile
</Button>
</>
)}
</Box>
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
<Latest
articles={articles}
onSelectArticle={handleSelectArticle}
onLoadMore={async () => {}}
/>
</>
);
}
};
if (loading) {
return (
<AppTheme {...props}>
@@ -26,7 +132,12 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
<Container
maxWidth="lg"
component="main"
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
Loading articles...
</Container>
@@ -41,7 +152,12 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
<Container
maxWidth="lg"
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}
</Container>
@@ -53,21 +169,39 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
<AppTheme {...props}>
<CssBaseline enableColorScheme />
<Container
maxWidth="lg"
component="main"
sx={{ display: 'flex', flexDirection: 'column', my: 16, gap: 4 }}
>
{selectedArticle === null ? (
<>
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
<Latest articles={articles.slice(0, 3)} /> {/* show 3 most recent */}
</>
) : (
<Article article={articles[selectedArticle]} onBack={handleBack} />
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Container
maxWidth="lg"
component="main"
sx={{
flex: '1 0 auto',
display: 'flex',
flexDirection: 'column',
my: 4,
gap: 4,
pb: view === 'home' ? 24 : 0,
}}
>
{renderView()}
</Container>
{view === 'home' && (
<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>
<Footer />
</Box>
</AppTheme>
);
}

View File

@@ -3,6 +3,7 @@ import { marked } from 'marked';
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
import { styled } from '@mui/material/styles';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { ArticleProps } from '../types/props';
const ArticleContainer = styled(Box)(({ theme }) => ({
maxWidth: '800px',
@@ -23,11 +24,9 @@ const CoverImage = styled('img')({
export default function Article({
article,
onBack,
}: {
article: any;
onBack: () => void;
}) {
onBack
}: ArticleProps) {
return (
<ArticleContainer>
<IconButton onClick={onBack} sx={{ mb: 2 }}>
@@ -44,16 +43,19 @@ export default function Article({
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
{article.title}
</Typography>
<Typography variant="h5" color="text.secondary" gutterBottom>
{article.subtitle}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
<Avatar src={article.authors[0].avatar} alt={article.authors[0].name} />
<Box>
<Typography variant="subtitle2">{article.authors[0].name}</Typography>
<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>
</Box>
</Box>

View File

@@ -1,24 +1,14 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
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 Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
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() {
return (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{'Copyright © '}
<Link color="text.secondary" href="https://mui.com/">
Sitemark
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Link color="text.secondary" href="https://www.aetoskia.com/">
{'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
</Link>
&nbsp;
{new Date().getFullYear()}
@@ -29,197 +19,17 @@ function Copyright() {
export default function Footer() {
return (
<React.Fragment>
<Divider />
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { xs: 4, sm: 8 },
py: { xs: 8, sm: 10 },
gap: { xs: 2, sm: 4 },
py: { xs: 2, sm: 4 },
textAlign: { sm: 'center', md: 'left' },
}}
>
<Box
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 }}>
&nbsp;&nbsp;
</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>
<Copyright />
</Container>
</React.Fragment>
);

View File

@@ -3,11 +3,12 @@ import Avatar from '@mui/material/Avatar';
import AvatarGroup from '@mui/material/AvatarGroup';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Pagination from '@mui/material/Pagination';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
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)({
@@ -88,69 +89,118 @@ function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
);
}
// ---- Latest component ---- //
interface LatestProps {
articles: Article[];
onSelectArticle?: (index: number) => void;
}
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
const [visibleCount, setVisibleCount] = React.useState(2);
const [loadingMore, setLoadingMore] = React.useState(false);
const [animating, setAnimating] = React.useState(false);
const loaderRef = React.useRef<HTMLDivElement | null>(null);
export default function Latest({ articles, onSelectArticle }: LatestProps) {
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(null);
const displayedArticles = articles.slice(0, visibleCount);
const handleFocus = (index: number) => setFocusedCardIndex(index);
const handleBlur = () => setFocusedCardIndex(null);
React.useEffect(() => {
if (!loaderRef.current) return;
// limit to 4-6 items for visual balance
const displayedArticles = articles.slice(0, 6);
const observer = new IntersectionObserver(
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 (
<div>
<Box>
<Typography variant="h2" gutterBottom>
Latest
</Typography>
<Grid container spacing={8} columns={12} sx={{ my: 4 }}>
{displayedArticles.map((article, 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">
{article.tag}
</Typography>
<TitleTypography
gutterBottom
variant="h6"
tabIndex={0}
onFocus={() => handleFocus(index)}
onBlur={handleBlur}
onClick={() => onSelectArticle?.(index)}
className={focusedCardIndex === index ? 'Mui-focused' : ''}
<Fade in timeout={animating ? 700 : 0}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 1,
height: '100%',
transition: 'transform 0.3s ease',
'&:hover': { transform: 'translateY(-3px)' },
}}
>
{article.title}
<NavigateNextRoundedIcon
className="arrow"
sx={{ fontSize: '1rem' }}
/>
</TitleTypography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{article.description}
</StyledTypography>
<Typography gutterBottom variant="caption" component="div">
{article.tag}
</Typography>
<Author authors={article.authors} />
</Box>
<TitleTypography
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>
<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>
</div>
</Box>
);
}

View File

@@ -0,0 +1,107 @@
import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
interface LoginProps {
onBack: () => void;
onRegister: () => void;
}
export default function Login({ onBack, onRegister }: LoginProps) {
const { login, loading, error, currentUser } = useAuth();
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await login(username, password);
};
// ✅ Auto-return if already logged in
React.useEffect(() => {
if (currentUser) onBack();
}, [currentUser]);
return (
<Box
sx={{
maxWidth: 400,
mx: 'auto',
mt: 8,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: 'background.paper',
}}
>
<IconButton onClick={onBack} sx={{ mb: 2 }}>
<ArrowBackRoundedIcon />
</IconButton>
<Typography variant="h4" fontWeight="bold" gutterBottom>
Sign In
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Please log in to continue
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
type="username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Button
fullWidth
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={loading}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Login'}
</Button>
</form>
<Typography
variant="body2"
color="text.secondary"
align="center"
sx={{ mt: 3 }}
>
Dont have an account?{' '}
<Link
component="button"
underline="hover"
color="primary"
onClick={onRegister}
sx={{ fontWeight: 500 }}
>
Register
</Link>
</Typography>
</Box>
);
}

View File

@@ -16,7 +16,6 @@ import { styled } from '@mui/material/styles';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
import { useArticles } from '../providers/Article';
const StyledCard = styled(Card)(({ theme }) => ({
display: 'flex',
@@ -138,7 +137,6 @@ export default function MainContent({
<Typography variant="h1" gutterBottom>
Blog
</Typography>
<Typography>Stay in the loop with the latest about our products</Typography>
</div>
<Box
sx={{

View File

@@ -0,0 +1,166 @@
import * as React from 'react';
import {
Box,
TextField,
Button,
Typography,
IconButton,
CircularProgress,
Avatar,
Alert,
} from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
interface ProfileProps {
onBack: () => void;
}
export default function Profile({ onBack }: ProfileProps) {
const { currentUser, loading, error, token, refreshAuthors, updateProfile } = useAuth();
const [formData, setFormData] = React.useState({
username: currentUser?.username || '',
name: currentUser?.name || '',
email: currentUser?.email || '',
avatar: currentUser?.avatar || '',
});
const [success, setSuccess] = React.useState<string | null>(null);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (currentUser) setFormData(currentUser);
}, [currentUser]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSave = async () => {
if (!currentUser) return;
try {
setSaving(true);
setSuccess(null);
const updatedUser = { ...currentUser, ...formData };
console.log('updatedUser');
console.log(updatedUser);
const updated = await updateProfile(updatedUser);
if (updated) setSuccess('Profile updated successfully');
} catch (err: any) {
console.error('Failed to update profile:', err);
} finally {
setSaving(false);
}
};
if (!currentUser) {
return (
<Box
sx={{
maxWidth: 400,
mx: 'auto',
mt: 8,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: 'background.paper',
}}
>
<Typography variant="h6" align="center">
You must be logged in to view your profile.
</Typography>
<Button fullWidth variant="outlined" sx={{ mt: 2 }} onClick={onBack}>
Back
</Button>
</Box>
);
}
return (
<Box
sx={{
maxWidth: 500,
mx: 'auto',
mt: 8,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: 'background.paper',
}}
>
<IconButton onClick={onBack} sx={{ mb: 2 }}>
<ArrowBackRoundedIcon />
</IconButton>
<Typography variant="h4" fontWeight="bold" gutterBottom>
Profile
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Avatar
src={formData.avatar}
alt={formData.name || formData.username}
sx={{ width: 64, height: 64 }}
/>
<TextField
label="Avatar URL"
name="avatar"
fullWidth
value={formData.avatar}
onChange={handleChange}
/>
</Box>
<TextField
fullWidth
label="Username"
name="username"
margin="normal"
value={formData.username}
onChange={handleChange}
/>
<TextField
fullWidth
label="Full Name"
name="name"
margin="normal"
value={formData.name}
onChange={handleChange}
/>
<TextField
fullWidth
label="Email"
name="email"
type="email"
margin="normal"
value={formData.email}
onChange={handleChange}
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mt: 2 }}>
{success}
</Alert>
)}
<Button
fullWidth
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={saving || loading}
onClick={handleSave}
>
{saving ? <CircularProgress size={24} color="inherit" /> : 'Save Changes'}
</Button>
</Box>
);
}

View File

@@ -0,0 +1,114 @@
import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Alert, } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
interface RegisterProps {
onBack: () => void;
}
export default function Register({ onBack }: RegisterProps) {
const { register, loading, error, currentUser } = useAuth();
const [username, setUsername] = React.useState('');
const [password1, setPassword1] = React.useState('');
const [password2, setPassword2] = React.useState('');
const [localError, setLocalError] = React.useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);
// ✅ Local validation
if (password1 !== password2) {
setLocalError("Passwords don't match");
return;
}
if (password1.length < 6) {
setLocalError('Password must be at least 6 characters long');
return;
}
// ✅ Call backend
await register(username, password1);
};
if (currentUser) {
// ✅ if logged in, auto-return to the article list
onBack();
return null;
}
return (
<Box
sx={{
maxWidth: 400,
mx: 'auto',
mt: 8,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: 'background.paper',
}}
>
<IconButton onClick={onBack} sx={{ mb: 2 }}>
<ArrowBackRoundedIcon />
</IconButton>
<Typography variant="h4" fontWeight="bold" gutterBottom>
Sign Up
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Please sign up to continue
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
type="username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password1}
onChange={(e) => setPassword1(e.target.value)}
required
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
required
/>
{(localError || error) && (
<Alert severity="error" sx={{ mt: 2 }}>
{localError || error}
</Alert>
)}
<Button
fullWidth
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={loading}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Register'}
</Button>
</form>
</Box>
);
}

View File

@@ -1,73 +1,44 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
import { api } from '../utils/api';
import { ArticleModel } from '../types/models';
import { ArticleContextModel } from '../types/contexts';
import { useAuth } from './Author';
interface Author {
_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 ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
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 [error, setError] = useState<string | null>(null);
const { token } = useAuth(); // ✅ access token if needed
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
const fetchArticles = async () => {
try {
setLoading(true);
setError(null);
// ✅ Use correct full endpoint from OpenAPI spec
const res = await axios.get<Article[]>(`${API_BASE}/articles`, {
params: { skip: 0, limit: 10 },
});
// ✅ Normalize if backend sends _id instead of id
const formatted = res.data.map((a) => ({
...a,
id: a._id || undefined,
}));
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 10 } });
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
setArticles(formatted);
} catch (err: any) {
console.error('Failed to fetch articles:', err);
setError(err.message || 'Failed to fetch articles');
setError(err.response?.data?.detail || 'Failed to fetch articles');
} finally {
setLoading(false);
}
};
/** 🔹 Auto-fetch articles whenever user logs in/out */
useEffect(() => {
fetchArticles();
}, []);
// Always load once on mount
// If endpoint requires JWT, fallback safely
if (!token) {
fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected)
} else {
fetchArticles();
}
}, [token]);
return (
<ArticleContext.Provider value={{ articles, loading, error, refreshArticles: fetchArticles }}>
@@ -76,7 +47,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
);
};
export const useArticles = (): ArticleContextType => {
export const useArticles = (): ArticleContextModel => {
const ctx = useContext(ArticleContext);
if (!ctx) throw new Error('useArticles must be used inside ArticleProvider');
return ctx;

View File

@@ -0,0 +1,145 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import { api } from '../utils/api';
import { AuthorModel } from '../types/models';
import { AuthContextModel } from '../types/contexts';
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
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);
/** 🔹 Register new user */
const register = async (username: string, password: string) => {
try {
setLoading(true);
setError(null);
const res = await api.post('/auth/register', { username, password });
return res.data; // returns PublicUser from backend
} catch (err: any) {
console.error('Registration failed:', err);
setError(err.response?.data?.detail || 'Registration failed');
} finally {
setLoading(false);
}
};
/** 🔹 Login and store JWT token */
const login = async (username: string, password: string) => {
try {
setLoading(true);
setError(null);
const res = await api.post('/auth/login', { username, 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 (JWT handled by api interceptor) */
const refreshAuthors = async () => {
try {
setLoading(true);
setError(null);
const res = await api.get<AuthorModel[]>('/authors');
setAuthors(res.data);
} catch (err: any) {
console.error('Failed to fetch authors:', err);
setError(err.response?.data?.detail || 'Failed to fetch authors');
} finally {
setLoading(false);
}
};
/** 🔹 Update current user (full model) */
const updateProfile = async (userData: AuthorModel) => {
if (!userData._id) {
console.error('updateProfile called without _id');
return;
}
try {
setLoading(true);
setError(null);
const res = await api.put<AuthorModel>(`/authors/${userData._id}`, userData);
setCurrentUser(res.data);
return res.data;
} catch (err: any) {
console.error('Profile update failed:', err);
setError(err.response?.data?.detail || 'Failed to update profile');
} finally {
setLoading(false);
}
};
/** 🔹 Auto-load current user if token exists */
const fetchCurrentUser = async () => {
if (!token) return;
try {
const me = await api.get<{ _id: string; username: string; email: string }>('/auth/me');
const author = await api.get<AuthorModel>(`/authors/${me.data._id}`);
const fullUser = { ...me.data, ...author.data };
setCurrentUser(fullUser);
} catch (err) {
console.error('Failed to fetch current user:', err);
logout(); // invalid/expired token
}
};
/** 🔹 On mount, try to fetch user if token exists */
useEffect(() => {
if (token) fetchCurrentUser();
}, [token]);
return (
<AuthContext.Provider
value={{
currentUser,
authors,
token,
loading,
error,
login,
logout,
register,
refreshAuthors,
updateProfile,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextModel => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
};

View File

@@ -0,0 +1,21 @@
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: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshAuthors: () => Promise<void>;
updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>;
}

30
src/blog/types/models.ts Normal file
View 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
View 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;
}

33
src/blog/utils/api.ts Normal file
View File

@@ -0,0 +1,33 @@
// src/utils/api.ts
import axios from 'axios';
const API_BASE = import.meta.env.VITE_API_BASE_URL;
export const api = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
// 🔹 Attach token from localStorage before each request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 🔹 Handle expired or invalid tokens globally
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.warn('Token expired or invalid. Logging out...');
localStorage.removeItem('token');
// Optionally: trigger a redirect or event
}
return Promise.reject(error);
}
);

View File

@@ -2,14 +2,17 @@ import * as React from 'react';
import { createRoot } from 'react-dom/client';
import Blog from './blog/Blog';
import { ArticleProvider } from './blog/providers/Article';
import { AuthProvider } from './blog/providers/Author';
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
<React.StrictMode>
<ArticleProvider>
<Blog />
</ArticleProvider>
<AuthProvider>
<ArticleProvider>
<Blog />
</ArticleProvider>
</AuthProvider>
</React.StrictMode>,
);