Compare commits
31 Commits
0.0.8
...
3e1ec9a3ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e1ec9a3ed | |||
| 3cac047709 | |||
| 1f21ab38fc | |||
| 1f5066a661 | |||
| 6798b64431 | |||
| 7fa61e6c2e | |||
| b09900f8ec | |||
| fc39d832c1 | |||
| 74cae4e4ea | |||
| 08c20c2613 | |||
| 7fece6f8f9 | |||
| e75beaac48 | |||
| 6d951b9ab5 | |||
| 6abdd443e0 | |||
| e9c654e138 | |||
| eddb251e4d | |||
| d29efe53e0 | |||
| 089e5e1716 | |||
| 2374d9a437 | |||
| ef7ed61665 | |||
| 8a29261a3e | |||
| 89aa1c6ce4 | |||
| 557e8ddfc9 | |||
| 0267aedf52 | |||
| 1c964a7fee | |||
| 661f8c915b | |||
| b2a7df5760 | |||
| 3bf0a5839c | |||
| 90e6a85fff | |||
| 42fe31fc69 | |||
| 4f442c369b |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.0.8",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
{currentUser.username}
|
||||
</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,28 +169,39 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
<AppTheme {...props}>
|
||||
<CssBaseline enableColorScheme />
|
||||
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
component="main"
|
||||
sx={{ display: 'flex', flexDirection: 'column', my: 4, gap: 4 }}
|
||||
>
|
||||
{selectedArticle === null ? (
|
||||
<>
|
||||
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
||||
<Latest
|
||||
articles={articles}
|
||||
onSelectArticle={handleSelectArticle}
|
||||
onLoadMore={async (offset, limit) => {
|
||||
// Optional: fetch more from API (if you want true pagination)
|
||||
// await fetchMoreArticles(offset, limit);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { marked } from 'marked';
|
||||
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
|
||||
import { Box, Typography, Divider, IconButton, Chip } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { ArticleMeta } from "./ArticleMeta";
|
||||
import { ArticleProps } from '../types/props';
|
||||
|
||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||
maxWidth: '800px',
|
||||
@@ -23,11 +25,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,19 +44,8 @@ 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}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<ArticleMeta article={article} />
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
|
||||
52
src/blog/components/ArticleCards/ArticleCardSize2.tsx
Normal file
52
src/blog/components/ArticleCards/ArticleCardSize2.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ArticleMeta } from "../ArticleMeta";
|
||||
import { ArticleCardProps } from "../../types/props";
|
||||
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||
|
||||
|
||||
export default function ArticleCardSize2({
|
||||
article,
|
||||
index,
|
||||
focusedCardIndex,
|
||||
onSelectArticle,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ArticleCardProps) {
|
||||
return (
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(index)}
|
||||
onFocus={() => onFocus(index)}
|
||||
onBlur={onBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||
>
|
||||
<StyledCardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{article.tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{article.title}
|
||||
</Typography>
|
||||
<StyledTypography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
{article.description}
|
||||
</StyledTypography>
|
||||
</div>
|
||||
</StyledCardContent>
|
||||
<ArticleMeta article={article} />
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
48
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
48
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { CardMedia, Typography } from '@mui/material';
|
||||
import { ArticleMeta } from "../ArticleMeta";
|
||||
import { ArticleCardProps } from "../../types/props";
|
||||
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||
|
||||
|
||||
export default function ArticleCardSize4({
|
||||
article,
|
||||
index,
|
||||
focusedCardIndex,
|
||||
onSelectArticle,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ArticleCardProps) {
|
||||
return (
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(index)}
|
||||
onFocus={() => onFocus(index)}
|
||||
onBlur={onBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt="green iguana"
|
||||
image={article.img}
|
||||
sx={{
|
||||
height: { sm: 'auto', md: '50%' },
|
||||
aspectRatio: { sm: '16 / 9', md: '' },
|
||||
}}
|
||||
/>
|
||||
<StyledCardContent>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{article.tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{article.title}
|
||||
</Typography>
|
||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||
{article.description}
|
||||
</StyledTypography>
|
||||
</StyledCardContent>
|
||||
<ArticleMeta article={article} />
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
49
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
49
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { CardMedia, Typography } from '@mui/material';
|
||||
import { ArticleMeta } from "../ArticleMeta";
|
||||
import { ArticleCardProps } from "../../types/props";
|
||||
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||
|
||||
|
||||
export default function ArticleCardSize6({
|
||||
article,
|
||||
index,
|
||||
focusedCardIndex,
|
||||
onSelectArticle,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ArticleCardProps) {
|
||||
return (
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(index)}
|
||||
onFocus={() => onFocus(index)}
|
||||
onBlur={onBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt={article.title}
|
||||
image={article.img}
|
||||
sx={{
|
||||
aspectRatio: '16 / 9',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
/>
|
||||
<StyledCardContent>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{article.tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{article.title}
|
||||
</Typography>
|
||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||
{article.description}
|
||||
</StyledTypography>
|
||||
</StyledCardContent>
|
||||
<ArticleMeta article={article} />
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
138
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
138
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box } from '@mui/material';
|
||||
import ArticleCardSize6 from './ArticleCardSize6';
|
||||
import ArticleCardSize4 from './ArticleCardSize4';
|
||||
import ArticleCardSize2 from './ArticleCardSize2';
|
||||
import { ArticleModel } from "../../types/models";
|
||||
import { ArticleGridProps } from "../../types/props";
|
||||
|
||||
export default function ArticleCardsGrid({
|
||||
articles,
|
||||
onSelectArticle,
|
||||
xs = 12,
|
||||
md6 = 6,
|
||||
md4 = 4,
|
||||
nested = 2,
|
||||
}: ArticleGridProps ) {
|
||||
|
||||
const visibleArticles = articles.slice(0, 6)
|
||||
const count = visibleArticles.length;
|
||||
|
||||
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleFocus = (index: number) => {
|
||||
setFocusedCardIndex(index);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setFocusedCardIndex(null);
|
||||
};
|
||||
|
||||
const renderCard = (article: ArticleModel, index: number, type: '6' | '4' | '2' = '6') => {
|
||||
const CardComponent =
|
||||
type === '6' ? ArticleCardSize6 :
|
||||
type === '4' ? ArticleCardSize4 :
|
||||
ArticleCardSize2;
|
||||
|
||||
return (
|
||||
<CardComponent
|
||||
key={index}
|
||||
article={article}
|
||||
index={index}
|
||||
focusedCardIndex={focusedCardIndex}
|
||||
onSelectArticle={onSelectArticle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} columns={12}>
|
||||
{/* ---- 2 articles: 6 | 6 ---- */}
|
||||
{count === 2 && (
|
||||
<>
|
||||
{visibleArticles.map((a, i) => (
|
||||
<Grid key={i} size={{ xs, md: md6 }}>
|
||||
{renderCard(a, i, '6')}
|
||||
</Grid>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 3 articles: 4 | 4 | 4 ---- */}
|
||||
{count === 3 && (
|
||||
<>
|
||||
{visibleArticles.map((a, i) => (
|
||||
<Grid key={i} size={{ xs, md: md4 }}>
|
||||
{renderCard(a, i, '4')}
|
||||
</Grid>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 4 articles: (6|6) + (6|6) ---- */}
|
||||
{count === 4 && (
|
||||
<>
|
||||
{visibleArticles.map((a, i) => (
|
||||
<Grid key={i} size={{ xs, md: md6 }}>
|
||||
{renderCard(a, i, '6')}
|
||||
</Grid>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 5 articles: (6|6) + (4|4|4) ---- */}
|
||||
{count === 5 && (
|
||||
<>
|
||||
{/* Row 1: 2 x size6 */}
|
||||
{visibleArticles.slice(0, 2).map((a, i) => (
|
||||
<Grid key={i} size={{ xs, md: md6 }}>
|
||||
{renderCard(a, i, '6')}
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{/* Row 2: 3 x size4 */}
|
||||
{visibleArticles.slice(2).map((a, i) => (
|
||||
<Grid key={i + 2} size={{ xs, md: md4 }}>
|
||||
{renderCard(a, i + 2, '4')}
|
||||
</Grid>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 6 articles: (6|6) + (4|2x2|4) ---- */}
|
||||
{count === 6 && (
|
||||
<>
|
||||
{/* Top row: 2 x size6 */}
|
||||
{visibleArticles.slice(0, 2).map((a, i) => (
|
||||
<Grid key={i} size={{ xs, md: md6 }}>
|
||||
{renderCard(a, i, '6')}
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{/* Bottom row: 4 + 2x2 + 4 */}
|
||||
<Grid size={{ xs, md: md4 }}>
|
||||
{renderCard(visibleArticles[2], 2, '4')}
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs, md: md4 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}
|
||||
>
|
||||
{visibleArticles.slice(3, 3 + nested).map((a, i) =>
|
||||
renderCard(a, i + 3, '2')
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs, md: md4 }}>
|
||||
{renderCard(visibleArticles[5], 5, '4')}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
53
src/blog/components/ArticleMeta.tsx
Normal file
53
src/blog/components/ArticleMeta.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import AvatarGroup from "@mui/material/AvatarGroup";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import {Typography} from "@mui/material";
|
||||
import React from "react";
|
||||
import { ArticleMetaProps } from "../types/props";
|
||||
|
||||
export function ArticleMeta({
|
||||
article,
|
||||
}: ArticleMetaProps ) {
|
||||
|
||||
const authors = article.authors;
|
||||
|
||||
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' }}
|
||||
>
|
||||
<AvatarGroup max={3}>
|
||||
{authors.map((author, index) => (
|
||||
<Avatar
|
||||
key={index}
|
||||
alt={author.name}
|
||||
src={author.avatar}
|
||||
sx={{ width: 24, height: 24 }}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Typography variant="caption">
|
||||
{authors.map((author) => author.name).join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(article.created_at).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{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 }}>
|
||||
•
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -7,8 +7,8 @@ import Typography from '@mui/material/Typography';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import type { Article } from '../providers/Article'; // ✅ import type for correctness
|
||||
import Fade from '@mui/material/Fade'; // ✅ for smooth appearance
|
||||
import { LatestProps } from "../types/props";
|
||||
import Fade from '@mui/material/Fade';
|
||||
|
||||
|
||||
const StyledTypography = styled(Typography)({
|
||||
@@ -89,13 +89,6 @@ function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Latest component ---- //
|
||||
interface LatestProps {
|
||||
articles: Article[];
|
||||
onSelectArticle?: (index: number) => void;
|
||||
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||
}
|
||||
|
||||
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
|
||||
const [visibleCount, setVisibleCount] = React.useState(2);
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
|
||||
107
src/blog/components/Login.tsx
Normal file
107
src/blog/components/Login.tsx
Normal 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 }}
|
||||
>
|
||||
Don’t have an account?{' '}
|
||||
<Link
|
||||
component="button"
|
||||
underline="hover"
|
||||
color="primary"
|
||||
onClick={onRegister}
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||
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 IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
||||
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
|
||||
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
||||
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' }}
|
||||
>
|
||||
<AvatarGroup max={3}>
|
||||
{authors.map((author, index) => (
|
||||
<Avatar
|
||||
key={index}
|
||||
alt={author.name}
|
||||
src={author.avatar}
|
||||
sx={{ width: 24, height: 24 }}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Typography variant="caption">
|
||||
{authors.map((author) => author.name).join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption">July 14, 2021</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
import { ArticleModel } from "../types/models";
|
||||
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
|
||||
|
||||
export function Search() {
|
||||
return (
|
||||
@@ -112,33 +37,47 @@ export default function MainContent({
|
||||
articles,
|
||||
onSelectArticle,
|
||||
}: {
|
||||
articles: any[];
|
||||
articles: ArticleModel[];
|
||||
onSelectArticle: (index: number) => void;
|
||||
}) {
|
||||
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleFocus = (index: number) => {
|
||||
setFocusedCardIndex(index);
|
||||
const [visibleArticles, setVisibleArticles] = React.useState<ArticleModel[]>(articles);
|
||||
const [activeTag, setActiveTag] = React.useState<string>('all');
|
||||
|
||||
const filterArticlesByTag = (tag: string) => {
|
||||
if (tag === 'all') {
|
||||
// 🟢 Show all articles
|
||||
setVisibleArticles(articles);
|
||||
setActiveTag('all');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTag === tag) {
|
||||
// 🟡 Toggle off the current tag → reset to all
|
||||
setVisibleArticles(articles);
|
||||
setActiveTag('all');
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔵 Filter by selected tag
|
||||
const filtered = articles.filter((article) => article.tag === tag);
|
||||
console.log('👀 All Articles:', articles);
|
||||
console.log(`👀 Filtered (${tag}):`, filtered);
|
||||
|
||||
setVisibleArticles(filtered);
|
||||
setActiveTag(tag);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setFocusedCardIndex(null);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
console.info('You clicked the filter chip.');
|
||||
const handleTagClick = (tag: string) => {
|
||||
setActiveTag((prev) => (prev === tag ? 'all' : tag));
|
||||
filterArticlesByTag(tag)
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div>
|
||||
<Typography variant="h1" gutterBottom>
|
||||
Blog
|
||||
</Typography>
|
||||
<Typography>Stay in the loop with the latest about our products</Typography>
|
||||
</div>
|
||||
<Typography variant="h1" gutterBottom>
|
||||
Blog
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'flex', sm: 'none' },
|
||||
@@ -172,43 +111,21 @@ export default function MainContent({
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Chip onClick={handleClick} size="medium" label="All categories" />
|
||||
<Chip
|
||||
onClick={handleClick}
|
||||
size="medium"
|
||||
label="Company"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
onClick={handleClick}
|
||||
size="medium"
|
||||
label="Product"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
onClick={handleClick}
|
||||
size="medium"
|
||||
label="Design"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
onClick={handleClick}
|
||||
size="medium"
|
||||
label="Engineering"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
{['all', 'infra', 'code', 'media', 'monitoring'].map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
size="medium"
|
||||
label={tag === 'all' ? 'All categories' : tag.charAt(0).toUpperCase() + tag.slice(1)}
|
||||
color={activeTag === tag ? 'primary' : 'default'}
|
||||
variant={activeTag === tag ? 'filled' : 'outlined'}
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
fontWeight: activeTag === tag ? 600 : 400,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -225,216 +142,10 @@ export default function MainContent({
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(0)}
|
||||
onFocus={() => handleFocus(0)}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === 0 ? 'Mui-focused' : ''}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt="green iguana"
|
||||
image={articles[0].img}
|
||||
sx={{
|
||||
aspectRatio: '16 / 9',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
/>
|
||||
<StyledCardContent>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{articles[0].tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{articles[0].title}
|
||||
</Typography>
|
||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||
{articles[0].description}
|
||||
</StyledTypography>
|
||||
</StyledCardContent>
|
||||
<Author authors={articles[0].authors} />
|
||||
</StyledCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(1)}
|
||||
onFocus={() => handleFocus(1)}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === 1 ? 'Mui-focused' : ''}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt="green iguana"
|
||||
image={articles[1].img}
|
||||
aspect-ratio="16 / 9"
|
||||
sx={{
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
/>
|
||||
<StyledCardContent>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{articles[1].tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{articles[1].title}
|
||||
</Typography>
|
||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||
{articles[1].description}
|
||||
</StyledTypography>
|
||||
</StyledCardContent>
|
||||
<Author authors={articles[1].authors} />
|
||||
</StyledCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(2)}
|
||||
onFocus={() => handleFocus(2)}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === 2 ? 'Mui-focused' : ''}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt="green iguana"
|
||||
image={articles[2].img}
|
||||
sx={{
|
||||
height: { sm: 'auto', md: '50%' },
|
||||
aspectRatio: { sm: '16 / 9', md: '' },
|
||||
}}
|
||||
/>
|
||||
<StyledCardContent>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{articles[2].tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{articles[2].title}
|
||||
</Typography>
|
||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||
{articles[2].description}
|
||||
</StyledTypography>
|
||||
</StyledCardContent>
|
||||
<Author authors={articles[2].authors} />
|
||||
</StyledCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}
|
||||
>
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(3)}
|
||||
onFocus={() => handleFocus(3)}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === 3 ? 'Mui-focused' : ''}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<StyledCardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{articles[3].tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{articles[3].title}
|
||||
</Typography>
|
||||
<StyledTypography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
{articles[3].description}
|
||||
</StyledTypography>
|
||||
</div>
|
||||
</StyledCardContent>
|
||||
<Author authors={articles[3].authors} />
|
||||
</StyledCard>
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(4)}
|
||||
onFocus={() => handleFocus(4)}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === 4 ? 'Mui-focused' : ''}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<StyledCardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{articles[4].tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{articles[4].title}
|
||||
</Typography>
|
||||
<StyledTypography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
{articles[4].description}
|
||||
</StyledTypography>
|
||||
</div>
|
||||
</StyledCardContent>
|
||||
<Author authors={articles[4].authors} />
|
||||
</StyledCard>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(5)}
|
||||
onFocus={() => handleFocus(5)}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className={focusedCardIndex === 5 ? 'Mui-focused' : ''}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt="green iguana"
|
||||
image={articles[5].img}
|
||||
sx={{
|
||||
height: { sm: 'auto', md: '50%' },
|
||||
aspectRatio: { sm: '16 / 9', md: '' },
|
||||
}}
|
||||
/>
|
||||
<StyledCardContent>
|
||||
<Typography gutterBottom variant="caption" component="div">
|
||||
{articles[5].tag}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{articles[5].title}
|
||||
</Typography>
|
||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||
{articles[5].description}
|
||||
</StyledTypography>
|
||||
</StyledCardContent>
|
||||
<Author authors={articles[5].authors} />
|
||||
</StyledCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<ArticleCardsGrid
|
||||
articles={visibleArticles}
|
||||
onSelectArticle={onSelectArticle}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
179
src/blog/components/Profile.tsx
Normal file
179
src/blog/components/Profile.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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, logout, 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
}
|
||||
|
||||
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>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="error"
|
||||
sx={{ mt: 3 }}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
114
src/blog/components/Register.tsx
Normal file
114
src/blog/components/Register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
/** 🔹 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: 100 } });
|
||||
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;
|
||||
|
||||
145
src/blog/providers/Author.tsx
Normal file
145
src/blog/providers/Author.tsx
Normal 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 the 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();
|
||||
}
|
||||
};
|
||||
|
||||
/** 🔹 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;
|
||||
};
|
||||
21
src/blog/types/contexts.ts
Normal file
21
src/blog/types/contexts.ts
Normal 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
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[];
|
||||
}
|
||||
36
src/blog/types/props.ts
Normal file
36
src/blog/types/props.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ArticleModel } from "./models";
|
||||
import {styled} from "@mui/material/styles";
|
||||
import Card from "@mui/material/Card";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ArticleMetaProps {
|
||||
article: ArticleModel;
|
||||
}
|
||||
|
||||
export interface ArticleCardProps {
|
||||
article: ArticleModel;
|
||||
index: number;
|
||||
focusedCardIndex: number | null;
|
||||
onSelectArticle: (index: number) => void;
|
||||
onFocus: (index: number) => void;
|
||||
onBlur: () => void;
|
||||
}
|
||||
|
||||
export interface ArticleGridProps {
|
||||
articles: ArticleModel[];
|
||||
onSelectArticle: (index: number) => void;
|
||||
xs?: number; // default 12 for mobile full-width
|
||||
md6?: number; // default 6 (half-width)
|
||||
md4?: number; // default 4 (third-width)
|
||||
nested?: 1 | 2; // number of stacked cards in a nested column
|
||||
}
|
||||
40
src/blog/types/styles.ts
Normal file
40
src/blog/types/styles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {styled} from "@mui/material/styles";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import {Typography} from "@mui/material";
|
||||
|
||||
export 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',
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledCardContent = styled(CardContent)({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
'&:last-child': {
|
||||
paddingBottom: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export const StyledTypography = styled(Typography)({
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
33
src/blog/utils/api.ts
Normal file
33
src/blog/utils/api.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user