Compare commits
80 Commits
0.0.5
...
3aaf328511
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aaf328511 | |||
| 635e99c183 | |||
| b8e4decfba | |||
| 459fa5855c | |||
| f52c4a5287 | |||
| 3a3f44c5b5 | |||
| 479ffb736c | |||
| 87bdafb6a3 | |||
| 383b424bdf | |||
| 0340e17467 | |||
| f15155d31c | |||
| c2e6daca13 | |||
| c0bcd0e3e4 | |||
| 333f931cff | |||
| 3960de3ecb | |||
| 763629faa1 | |||
| a7e3ed46cb | |||
| 4a8c59895e | |||
| ec9b5c905a | |||
| d7e9827819 | |||
| ae0bc7dd12 | |||
| 1e6c80f1b3 | |||
| 8ff8b9236e | |||
| 142b169108 | |||
| 80bf87529e | |||
| 5582d18a01 | |||
| 913755d971 | |||
| 8838ff10f4 | |||
| 7a28dde7d5 | |||
| d6c84abdf6 | |||
| 1b755968dd | |||
| 33e9d70b98 | |||
| ce91526599 | |||
| 73d64ea497 | |||
| e16804b65d | |||
| 945912f16d | |||
| 4e2af82573 | |||
| bd8aea46b1 | |||
| 10aa43fa27 | |||
| 068a741706 | |||
| 7faedcf2f9 | |||
| 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 | |||
| 6b8d351fed | |||
| fd5093a1f8 | |||
| d3acf05b08 | |||
| bc6bfef6ea | |||
| eedb9a24f3 | |||
| 998c3d490d | |||
| bb3f733ffc | |||
| ce7b5dab6b |
14
.drone.yml
14
.drone.yml
@@ -63,6 +63,9 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: docker:24
|
image: docker:24
|
||||||
|
environment:
|
||||||
|
API_BASE_URL:
|
||||||
|
from_secret: API_BASE_URL
|
||||||
volumes:
|
volumes:
|
||||||
- name: dockersock
|
- name: dockersock
|
||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
@@ -70,7 +73,12 @@ steps:
|
|||||||
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
- echo "🔨 Building Docker image apps/blog:$IMAGE_TAG ..."
|
- echo "🔨 Building Docker image apps/blog:$IMAGE_TAG ..."
|
||||||
- docker build --network=host -t apps/blog:$IMAGE_TAG -t apps/blog:latest /drone/src
|
- |
|
||||||
|
docker build --network=host \
|
||||||
|
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
||||||
|
-t apps/blog:$IMAGE_TAG \
|
||||||
|
-t apps/blog:latest \
|
||||||
|
/drone/src
|
||||||
|
|
||||||
- name: push-image
|
- name: push-image
|
||||||
image: docker:24
|
image: docker:24
|
||||||
@@ -108,9 +116,6 @@ steps:
|
|||||||
|
|
||||||
- name: run-container
|
- name: run-container
|
||||||
image: docker:24
|
image: docker:24
|
||||||
environment:
|
|
||||||
API_BASE_URL:
|
|
||||||
from_secret: API_BASE_URL
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: dockersock
|
- name: dockersock
|
||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
@@ -123,7 +128,6 @@ steps:
|
|||||||
--name blog \
|
--name blog \
|
||||||
-p 3002:3000 \
|
-p 3002:3000 \
|
||||||
-e NODE_ENV=production \
|
-e NODE_ENV=production \
|
||||||
-e VITE_API_BASE_URL="$API_BASE_URL" \
|
|
||||||
--restart always \
|
--restart always \
|
||||||
apps/blog:$IMAGE_TAG
|
apps/blog:$IMAGE_TAG
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
RUN npm run build
|
ARG VITE_API_BASE_URL
|
||||||
|
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL npm run build
|
||||||
|
|
||||||
# Stage 2: Static file server (BusyBox)
|
# Stage 2: Static file server (BusyBox)
|
||||||
FROM busybox:latest
|
FROM busybox:latest
|
||||||
|
|||||||
1385
package-lock.json
generated
1385
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.0.4",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"@mui/icons-material": "latest",
|
"@mui/icons-material": "latest",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
"react-dom": "latest",
|
"react-dom": "latest",
|
||||||
|
"react-markdown": "latest",
|
||||||
"markdown-to-jsx": "latest",
|
"markdown-to-jsx": "latest",
|
||||||
|
"remark-gfm": "latest",
|
||||||
"marked": "latest",
|
"marked": "latest",
|
||||||
"axios": "latest"
|
"axios": "latest"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,138 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
import AppTheme from '../shared-theme/AppTheme';
|
import AppTheme from '../shared-theme/AppTheme';
|
||||||
import MainContent from './components/MainContent';
|
import MainContent from './components/MainContent';
|
||||||
import Article from './components/Article';
|
import ArticleView from './components/Article/ArticleView';
|
||||||
|
import ArticleEditor from './components/Article/ArticleEditor';
|
||||||
import Latest from './components/Latest';
|
import Latest from './components/Latest';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import { useArticles } from './providers/Article'; // ✅ custom hook for global articles
|
import Login from './components/Login';
|
||||||
|
import Register from './components/Register';
|
||||||
|
import Profile from './components/Profile';
|
||||||
|
import { useArticles } from './providers/Article';
|
||||||
|
import { useAuth } from './providers/Author';
|
||||||
|
import { View, useViewRouter } from "./types/views";
|
||||||
|
import { ArticleModel } from "./types/models";
|
||||||
|
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
|
||||||
|
|
||||||
|
function HomeView({ currentUser, open_login, open_profile, open_create, articles, openArticle }: any) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2, gap: 1 }}>
|
||||||
|
{!currentUser ? (
|
||||||
|
<Button variant="outlined" onClick={open_login}>Login</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outlined" onClick={open_profile}>
|
||||||
|
{currentUser.username}
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={open_create}>
|
||||||
|
New Article
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<MainContent articles={articles} onSelectArticle={openArticle} />
|
||||||
|
<Latest articles={articles} onSelectArticle={openArticle} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||||
const { articles, loading, error } = useArticles(); // ✅ Hook must be inside component
|
const { articles, loading, error } = useArticles();
|
||||||
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
const handleSelectArticle = (index: number) => {
|
const [ui, setUI] = React.useState({
|
||||||
setSelectedArticle(index);
|
selectedArticle: null as number | null,
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
view: "home" as View,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
goBack,
|
||||||
|
navigateToChildren,
|
||||||
|
openArticle,
|
||||||
|
} = useViewRouter(setUI);
|
||||||
|
|
||||||
|
type RouterContext = {
|
||||||
|
ui: any;
|
||||||
|
articles: ArticleModel[];
|
||||||
|
currentUser: any;
|
||||||
|
openArticle: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => setSelectedArticle(null);
|
type ViewComponentEntry<P> = {
|
||||||
|
component: React.ComponentType<P>;
|
||||||
|
extraProps?: (ctx: RouterContext) => Partial<P>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
|
||||||
|
home: {
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
|
||||||
|
login: {
|
||||||
|
component: Login,
|
||||||
|
},
|
||||||
|
|
||||||
|
register: {
|
||||||
|
component: Register,
|
||||||
|
},
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
component: Profile,
|
||||||
|
},
|
||||||
|
|
||||||
|
article: {
|
||||||
|
component: ArticleView,
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
article: articles[ui.selectedArticle!],
|
||||||
|
}) satisfies Partial<ArticleViewProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
article: ui.selectedArticle !== null ? articles[ui.selectedArticle] : null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
create: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: () => ({
|
||||||
|
article: null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderView = () => {
|
||||||
|
const entry = VIEW_COMPONENTS[ui.view];
|
||||||
|
const ViewComponent = entry.component;
|
||||||
|
|
||||||
|
const childNav = navigateToChildren(ui.view);
|
||||||
|
|
||||||
|
const ctx: RouterContext = {
|
||||||
|
ui,
|
||||||
|
articles,
|
||||||
|
currentUser,
|
||||||
|
|
||||||
|
openArticle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const extraProps = entry.extraProps ? entry.extraProps(ctx) : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewComponent
|
||||||
|
{...ctx}
|
||||||
|
{...childNav}
|
||||||
|
onBack={() => goBack(ui.view)}
|
||||||
|
{...extraProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +141,12 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
component="main"
|
component="main"
|
||||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Loading articles...
|
Loading articles...
|
||||||
</Container>
|
</Container>
|
||||||
@@ -41,7 +161,12 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
component="main"
|
component="main"
|
||||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Failed to load articles: {error}
|
Failed to load articles: {error}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -53,21 +178,39 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
<AppTheme {...props}>
|
<AppTheme {...props}>
|
||||||
<CssBaseline enableColorScheme />
|
<CssBaseline enableColorScheme />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
component="main"
|
component="main"
|
||||||
sx={{ display: 'flex', flexDirection: 'column', my: 16, gap: 4 }}
|
sx={{
|
||||||
|
flex: '1 0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
my: 4,
|
||||||
|
gap: 4,
|
||||||
|
pb: ui.view === 'home' ? 24 : 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selectedArticle === null ? (
|
{renderView()}
|
||||||
<>
|
|
||||||
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
|
||||||
<Latest articles={articles.slice(0, 3)} /> {/* show 3 most recent */}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Article article={articles[selectedArticle]} onBack={handleBack} />
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
{ui.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 />
|
<Footer />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</AppTheme>
|
</AppTheme>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
192
src/blog/components/Article/ArticleEditor.tsx
Normal file
192
src/blog/components/Article/ArticleEditor.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Typography, Divider, IconButton, TextField, Button } from '@mui/material';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import { ArticleEditorProps } from '../../types/props';
|
||||||
|
import { ArticleModel } from "../../types/models";
|
||||||
|
import { useUpload } from "../../providers/Upload";
|
||||||
|
import { useArticles } from "../../providers/Article";
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import ImageUploadField from "../ImageUploadField";
|
||||||
|
|
||||||
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: theme.spacing(4),
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CoverImage = styled('img')({
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: '12px',
|
||||||
|
marginTop: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ArticleView({
|
||||||
|
article,
|
||||||
|
onBack,
|
||||||
|
}: ArticleEditorProps) {
|
||||||
|
|
||||||
|
const { uploadFile } = useUpload();
|
||||||
|
const { updateArticle, createArticle } = useArticles();
|
||||||
|
|
||||||
|
const [title, setTitle] = React.useState(article?.title ?? "");
|
||||||
|
const [description, setDescription] = React.useState(article?.description ?? "");
|
||||||
|
const [tag, setTag] = React.useState(article?.tag ?? "");
|
||||||
|
const [img, setImg] = React.useState(article?.img ?? "");
|
||||||
|
const [uploadingCoverImage, setUploadingCoverImage] = React.useState(false);
|
||||||
|
const [content, setContent] = React.useState(article?.content ?? "");
|
||||||
|
|
||||||
|
const handleCoverImageUpload = async (file: File) => {
|
||||||
|
setUploadingCoverImage(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const img = await uploadFile(file);
|
||||||
|
if (img) {
|
||||||
|
setImg(img);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Avatar upload failed:", err);
|
||||||
|
} finally {
|
||||||
|
setUploadingCoverImage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveArticle = async (articleData: Partial<ArticleModel>) => {
|
||||||
|
// If _id exists → UPDATE
|
||||||
|
if (articleData._id) {
|
||||||
|
console.log("Updating article with ID:", articleData._id);
|
||||||
|
return await updateArticle(articleData as ArticleModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No _id → CREATE
|
||||||
|
console.log("Creating new article:", articleData);
|
||||||
|
return await createArticle(articleData as ArticleModel);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ArticleContainer>
|
||||||
|
{/* BACK BUTTON */}
|
||||||
|
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||||
|
<ArrowBackRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* TAG */}
|
||||||
|
<TextField
|
||||||
|
label="Tag"
|
||||||
|
fullWidth
|
||||||
|
value={tag}
|
||||||
|
onChange={(e) => setTag(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TITLE */}
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
fullWidth
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
fullWidth
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
label="Cover Image"
|
||||||
|
value={img}
|
||||||
|
uploading={uploadingCoverImage}
|
||||||
|
onUpload={handleCoverImageUpload}
|
||||||
|
size={128}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* MARKDOWN EDITOR */}
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'stretch'
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6">Content</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="textarea"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '300px',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
resize: 'vertical',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* LIVE PREVIEW */}
|
||||||
|
<Typography variant="h6" sx={{ mt: 4 }}>
|
||||||
|
Preview
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
'& h3': { fontWeight: 600, mt: 4 },
|
||||||
|
'& p': { color: 'text.primary', lineHeight: 1.8, mt: 2 },
|
||||||
|
'& em': { fontStyle: 'italic' },
|
||||||
|
'& ul': { pl: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||||
|
<Button variant="outlined" color="secondary" onClick={onBack}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() =>
|
||||||
|
handleSaveArticle({
|
||||||
|
...article,
|
||||||
|
title,
|
||||||
|
tag,
|
||||||
|
img,
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</ArticleContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { marked } from 'marked';
|
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 { styled } from '@mui/material/styles';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleViewProps } from '../../types/props';
|
||||||
|
import {useAuth} from "../../providers/Author";
|
||||||
|
|
||||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
@@ -21,19 +25,43 @@ const CoverImage = styled('img')({
|
|||||||
marginBottom: '24px',
|
marginBottom: '24px',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Article({
|
export default function ArticleView({
|
||||||
article,
|
article,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
open_editor,
|
||||||
article: any;
|
}: ArticleViewProps) {
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
const { currentUser } = useAuth();
|
||||||
|
const onEdit = open_editor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArticleContainer>
|
<ArticleContainer>
|
||||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBackRoundedIcon />
|
<ArrowBackRoundedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<IconButton onClick={onEdit}>
|
||||||
|
<EditRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
<Chip
|
<Chip
|
||||||
label={article.tag}
|
label={article.tag}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -41,26 +69,14 @@ export default function Article({
|
|||||||
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
<CoverImage
|
||||||
{article.title}
|
src={(
|
||||||
</Typography>
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
<Typography variant="h5" color="text.secondary" gutterBottom>
|
"/" +
|
||||||
{article.subtitle}
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
</Typography>
|
)}
|
||||||
|
alt={article.title}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
|
||||||
|
|
||||||
<CoverImage src={article.img} alt={article.title} />
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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 ArticleCardSize12({
|
||||||
|
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={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
52
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
52
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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={article.title}
|
||||||
|
image={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
53
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
152
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
152
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Grid, Box } from '@mui/material';
|
||||||
|
import ArticleCardSize12 from './ArticleCardSize12';
|
||||||
|
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,
|
||||||
|
md12 = 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: '12' | '6' | '4' | '2' = '12') => {
|
||||||
|
const CardComponent =
|
||||||
|
type === '12' ? ArticleCardSize12 :
|
||||||
|
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}>
|
||||||
|
{/* ---- 1 article: 12 ---- */}
|
||||||
|
{count === 1 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md12 }}>
|
||||||
|
{renderCard(a, i, '12')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/blog/components/ArticleMeta.tsx
Normal file
57
src/blog/components/ArticleMeta.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(author.avatar?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
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 * as React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
import Divider from '@mui/material/Divider';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
|
||||||
import Link from '@mui/material/Link';
|
import Link from '@mui/material/Link';
|
||||||
import Stack from '@mui/material/Stack';
|
|
||||||
import TextField from '@mui/material/TextField';
|
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
|
||||||
import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
|
||||||
import TwitterIcon from '@mui/icons-material/X';
|
|
||||||
|
|
||||||
function Copyright() {
|
function Copyright() {
|
||||||
return (
|
return (
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{'Copyright © '}
|
<Link color="text.secondary" href="https://www.aetoskia.com/">
|
||||||
<Link color="text.secondary" href="https://mui.com/">
|
{'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
|
||||||
Sitemark
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{new Date().getFullYear()}
|
{new Date().getFullYear()}
|
||||||
@@ -29,197 +19,17 @@ function Copyright() {
|
|||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Divider />
|
|
||||||
<Container
|
<Container
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: { xs: 4, sm: 8 },
|
gap: { xs: 2, sm: 4 },
|
||||||
py: { xs: 8, sm: 10 },
|
py: { xs: 2, sm: 4 },
|
||||||
textAlign: { sm: 'center', md: 'left' },
|
textAlign: { sm: 'center', md: 'left' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
|
||||||
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 />
|
<Copyright />
|
||||||
</div>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={1}
|
|
||||||
useFlexGap
|
|
||||||
sx={{ justifyContent: 'left', color: 'text.secondary' }}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
href="https://github.com/mui"
|
|
||||||
aria-label="GitHub"
|
|
||||||
sx={{ alignSelf: 'center' }}
|
|
||||||
>
|
|
||||||
<GitHubIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
href="https://x.com/MaterialUI"
|
|
||||||
aria-label="X"
|
|
||||||
sx={{ alignSelf: 'center' }}
|
|
||||||
>
|
|
||||||
<TwitterIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
href="https://www.linkedin.com/company/mui/"
|
|
||||||
aria-label="LinkedIn"
|
|
||||||
sx={{ alignSelf: 'center' }}
|
|
||||||
>
|
|
||||||
<LinkedInIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
</Container>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
45
src/blog/components/ImageUploadField.tsx
Normal file
45
src/blog/components/ImageUploadField.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Box, Button, Avatar, CircularProgress } from "@mui/material";
|
||||||
|
import { ImageUploadFieldProps } from "../types/props";
|
||||||
|
|
||||||
|
export default function ImageUploadField({
|
||||||
|
label = "Upload Image",
|
||||||
|
value,
|
||||||
|
uploading = false,
|
||||||
|
onUpload,
|
||||||
|
size = 64,
|
||||||
|
}: ImageUploadFieldProps) {
|
||||||
|
|
||||||
|
const imgSrc = value
|
||||||
|
? import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
value.replace(/^\/+/, "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 3 }}>
|
||||||
|
<Avatar
|
||||||
|
src={imgSrc}
|
||||||
|
sx={{ width: size, height: size, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
disabled={uploading}
|
||||||
|
startIcon={uploading && <CircularProgress size={16} />}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading..." : label}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onUpload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import Avatar from '@mui/material/Avatar';
|
|||||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Pagination from '@mui/material/Pagination';
|
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
||||||
import type { Article } from '../providers/Article'; // ✅ import type for correctness
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import { LatestProps } from "../types/props";
|
||||||
|
import Fade from '@mui/material/Fade';
|
||||||
|
|
||||||
|
|
||||||
const StyledTypography = styled(Typography)({
|
const StyledTypography = styled(Typography)({
|
||||||
@@ -74,7 +75,11 @@ function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
|||||||
<Avatar
|
<Avatar
|
||||||
key={index}
|
key={index}
|
||||||
alt={author.name}
|
alt={author.name}
|
||||||
src={author.avatar}
|
src={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(author.avatar?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
sx={{ width: 24, height: 24 }}
|
sx={{ width: 24, height: 24 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -88,29 +93,66 @@ function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Latest component ---- //
|
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
|
||||||
interface LatestProps {
|
const [visibleCount, setVisibleCount] = React.useState(2);
|
||||||
articles: Article[];
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
onSelectArticle?: (index: number) => void;
|
const [animating, setAnimating] = React.useState(false);
|
||||||
}
|
const loaderRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
export default function Latest({ articles, onSelectArticle }: LatestProps) {
|
const displayedArticles = articles.slice(0, visibleCount);
|
||||||
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleFocus = (index: number) => setFocusedCardIndex(index);
|
React.useEffect(() => {
|
||||||
const handleBlur = () => setFocusedCardIndex(null);
|
if (!loaderRef.current) return;
|
||||||
|
|
||||||
// limit to 4-6 items for visual balance
|
const observer = new IntersectionObserver(
|
||||||
const displayedArticles = articles.slice(0, 6);
|
async (entries) => {
|
||||||
|
const first = entries[0];
|
||||||
|
if (first.isIntersecting && !loadingMore && visibleCount < articles.length) {
|
||||||
|
console.log('🟡 Intersection triggered — loading more blogs...');
|
||||||
|
setLoadingMore(true);
|
||||||
|
|
||||||
|
// simulate API load delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
if (onLoadMore) {
|
||||||
|
console.log(`📡 Calling onLoadMore(offset=${visibleCount}, limit=2)`);
|
||||||
|
await onLoadMore(visibleCount, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnimating(true);
|
||||||
|
setVisibleCount((prev) => {
|
||||||
|
const newCount = prev + 2;
|
||||||
|
console.log(`✅ Increasing visibleCount from ${prev} → ${newCount}`);
|
||||||
|
return newCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => setAnimating(false), 600);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const current = loaderRef.current;
|
||||||
|
observer.observe(current);
|
||||||
|
|
||||||
|
console.log('👀 IntersectionObserver attached to loaderRef:', loaderRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (current) observer.unobserve(current);
|
||||||
|
console.log('🧹 IntersectionObserver detached');
|
||||||
|
};
|
||||||
|
}, [loadingMore, visibleCount, articles.length, onLoadMore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Box>
|
||||||
<Typography variant="h2" gutterBottom>
|
<Typography variant="h2" gutterBottom>
|
||||||
Latest
|
Latest
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={8} columns={12} sx={{ my: 4 }}>
|
<Grid container spacing={8} columns={12} sx={{ my: 4 }}>
|
||||||
{displayedArticles.map((article, index) => (
|
{displayedArticles.map((article, index) => (
|
||||||
<Grid key={index} size={{ xs: 12, sm: 6 }}>
|
<Grid key={index} size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Fade in timeout={animating ? 700 : 0}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -118,6 +160,8 @@ export default function Latest({ articles, onSelectArticle }: LatestProps) {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
'&:hover': { transform: 'translateY(-3px)' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
@@ -128,29 +172,39 @@ export default function Latest({ articles, onSelectArticle }: LatestProps) {
|
|||||||
gutterBottom
|
gutterBottom
|
||||||
variant="h6"
|
variant="h6"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onFocus={() => handleFocus(index)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onClick={() => onSelectArticle?.(index)}
|
onClick={() => onSelectArticle?.(index)}
|
||||||
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
|
||||||
>
|
>
|
||||||
{article.title}
|
{article.title}
|
||||||
<NavigateNextRoundedIcon
|
<NavigateNextRoundedIcon className="arrow" sx={{ fontSize: '1rem' }} />
|
||||||
className="arrow"
|
|
||||||
sx={{ fontSize: '1rem' }}
|
|
||||||
/>
|
|
||||||
</TitleTypography>
|
</TitleTypography>
|
||||||
|
|
||||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
{article.description}
|
{article.description}
|
||||||
</StyledTypography>
|
</StyledTypography>
|
||||||
|
|
||||||
<Author authors={article.authors} />
|
<Author authors={article.authors} />
|
||||||
</Box>
|
</Box>
|
||||||
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 4 }}>
|
|
||||||
<Pagination hidePrevButton hideNextButton count={10} boundaryCount={10} />
|
<Box
|
||||||
|
ref={loaderRef}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
py: 3,
|
||||||
|
opacity: loadingMore ? 1 : 0.6,
|
||||||
|
transition: 'opacity 0.4s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<CircularProgress size={32} thickness={5} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption">Scroll to load more...</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,92 +1,16 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import Avatar from '@mui/material/Avatar';
|
|
||||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
|
||||||
import Box from '@mui/material/Box';
|
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 Chip from '@mui/material/Chip';
|
||||||
import Grid from '@mui/material/Grid';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||||
import { styled } from '@mui/material/styles';
|
|
||||||
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
||||||
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
|
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
|
||||||
|
|
||||||
import { useArticles } from '../providers/Article';
|
import { ArticleModel } from "../types/models";
|
||||||
|
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Search() {
|
export function Search() {
|
||||||
return (
|
return (
|
||||||
@@ -113,33 +37,47 @@ export default function MainContent({
|
|||||||
articles,
|
articles,
|
||||||
onSelectArticle,
|
onSelectArticle,
|
||||||
}: {
|
}: {
|
||||||
articles: any[];
|
articles: ArticleModel[];
|
||||||
onSelectArticle: (index: number) => void;
|
onSelectArticle: (index: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFocus = (index: number) => {
|
const [visibleArticles, setVisibleArticles] = React.useState<ArticleModel[]>(articles);
|
||||||
setFocusedCardIndex(index);
|
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 = () => {
|
const handleTagClick = (tag: string) => {
|
||||||
setFocusedCardIndex(null);
|
setActiveTag((prev) => (prev === tag ? 'all' : tag));
|
||||||
};
|
filterArticlesByTag(tag)
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
console.info('You clicked the filter chip.');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<div>
|
|
||||||
<Typography variant="h1" gutterBottom>
|
<Typography variant="h1" gutterBottom>
|
||||||
Blog
|
Blog
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography>Stay in the loop with the latest about our products</Typography>
|
|
||||||
</div>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'flex', sm: 'none' },
|
display: { xs: 'flex', sm: 'none' },
|
||||||
@@ -173,43 +111,21 @@ export default function MainContent({
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip onClick={handleClick} size="medium" label="All categories" />
|
{['all', 'infra', 'code', 'media', 'monitoring'].map((tag) => (
|
||||||
<Chip
|
<Chip
|
||||||
onClick={handleClick}
|
key={tag}
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
size="medium"
|
size="medium"
|
||||||
label="Company"
|
label={tag === 'all' ? 'All categories' : tag.charAt(0).toUpperCase() + tag.slice(1)}
|
||||||
|
color={activeTag === tag ? 'primary' : 'default'}
|
||||||
|
variant={activeTag === tag ? 'filled' : 'outlined'}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: 'transparent',
|
borderRadius: '8px',
|
||||||
border: 'none',
|
fontWeight: activeTag === tag ? 600 : 400,
|
||||||
}}
|
textTransform: 'capitalize',
|
||||||
/>
|
|
||||||
<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',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -226,216 +142,10 @@ export default function MainContent({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container spacing={2} columns={12}>
|
<ArticleCardsGrid
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
articles={visibleArticles}
|
||||||
<StyledCard
|
onSelectArticle={onSelectArticle}
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/blog/components/Profile.tsx
Normal file
190
src/blog/components/Profile.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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';
|
||||||
|
import { useUpload } from "../providers/Upload";
|
||||||
|
import ImageUploadField from './ImageUploadField';
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Profile({ onBack }: ProfileProps) {
|
||||||
|
const { currentUser, loading, error, logout, updateProfile } = useAuth();
|
||||||
|
const { uploadFile } = useUpload();
|
||||||
|
const [formData, setFormData] = React.useState({
|
||||||
|
username: currentUser?.username || '',
|
||||||
|
name: currentUser?.name || '',
|
||||||
|
email: currentUser?.email || '',
|
||||||
|
avatar: currentUser?.avatar || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = React.useState(false);
|
||||||
|
const [success, setSuccess] = React.useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentUser) setFormData(currentUser);
|
||||||
|
console.log("Current User:", currentUser);
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (file: File) => {
|
||||||
|
setUploadingAvatar(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatar = await uploadFile(file);
|
||||||
|
if (avatar) {
|
||||||
|
setFormData((prev) => ({ ...prev, avatar: avatar }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Avatar upload failed:", err);
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
const updatedUser = { ...currentUser, ...formData };
|
||||||
|
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>
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
label="Upload Avatar"
|
||||||
|
value={formData.avatar}
|
||||||
|
uploading={uploadingAvatar}
|
||||||
|
onUpload={handleAvatarUpload}
|
||||||
|
size={64}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,82 +1,141 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
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 {
|
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
|
||||||
_id?: string | null;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Article {
|
|
||||||
_id?: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
img: string;
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
authors: Author[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArticleContextType {
|
|
||||||
articles: Article[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refreshArticles: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ArticleContext = createContext<ArticleContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
|
||||||
|
|
||||||
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { token, currentUser } = useAuth();
|
||||||
|
|
||||||
|
const upsertArticleInList = (updated: ArticleModel) => {
|
||||||
|
setArticles(prev => {
|
||||||
|
const exists = prev.some(a => a._id === updated._id);
|
||||||
|
if (exists) {
|
||||||
|
// UPDATE → replace item
|
||||||
|
return prev.map(a => (a._id === updated._id ? updated : a));
|
||||||
|
} else {
|
||||||
|
// CREATE → append to top
|
||||||
|
return [updated, ...prev];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Author IDs must be strings for API, so we normalize here */
|
||||||
|
const normalizeArticleForApi = (article: Partial<ArticleModel>) => {
|
||||||
|
// Extract existing authors as a list of IDs (string[])
|
||||||
|
const existingIds = (article.authors ?? []).map(a =>
|
||||||
|
typeof a === "string" ? a : a._id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inject currentUser if missing
|
||||||
|
const allAuthorIds = currentUser?._id
|
||||||
|
? Array.from(new Set([...existingIds, currentUser._id])) // dedupe
|
||||||
|
: existingIds;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...article,
|
||||||
|
authors: allAuthorIds,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// ✅ Use correct full endpoint from OpenAPI spec
|
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 100 } });
|
||||||
const res = await axios.get<Article[]>(`${API_BASE}/articles`, {
|
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
|
||||||
params: { skip: 0, limit: 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ Normalize if backend sends _id instead of id
|
|
||||||
const formatted = res.data.map((a) => ({
|
|
||||||
...a,
|
|
||||||
id: a._id || undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setArticles(formatted);
|
setArticles(formatted);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch articles:', err);
|
console.error('Failed to fetch articles:', err);
|
||||||
setError(err.message || 'Failed to fetch articles');
|
setError(err.response?.data?.detail || 'Failed to fetch articles');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 🔹 Update article */
|
||||||
|
const updateArticle = async (articleData: ArticleModel) => {
|
||||||
|
if (!articleData._id) {
|
||||||
|
console.error('updateArticle called without _id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentUser) {
|
||||||
|
console.error('updateArticle called without logged in user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedArticleData = normalizeArticleForApi(articleData);
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
|
||||||
|
upsertArticleInList(res.data);
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Article update failed:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Failed to update article');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Create article */
|
||||||
|
const createArticle = async (articleData: ArticleModel) => {
|
||||||
|
if (articleData._id) {
|
||||||
|
console.error('createArticle called with _id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedArticleData = normalizeArticleForApi(articleData);
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
|
||||||
|
upsertArticleInList(res.data);
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Article create failed:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Failed to create article');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Auto-fetch articles whenever user logs in/out */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 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();
|
fetchArticles();
|
||||||
}, []);
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArticleContext.Provider value={{ articles, loading, error, refreshArticles: fetchArticles }}>
|
<ArticleContext.Provider value={{
|
||||||
|
articles,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshArticles: fetchArticles,
|
||||||
|
updateArticle,
|
||||||
|
createArticle,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ArticleContext.Provider>
|
</ArticleContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useArticles = (): ArticleContextType => {
|
export const useArticles = (): ArticleContextModel => {
|
||||||
const ctx = useContext(ArticleContext);
|
const ctx = useContext(ArticleContext);
|
||||||
if (!ctx) throw new Error('useArticles must be used inside ArticleProvider');
|
if (!ctx) throw new Error('useArticles must be used inside ArticleProvider');
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|||||||
144
src/blog/providers/Author.tsx
Normal file
144
src/blog/providers/Author.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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;
|
||||||
|
} 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;
|
||||||
|
};
|
||||||
56
src/blog/providers/Upload.tsx
Normal file
56
src/blog/providers/Upload.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { createContext, useContext, useState } from "react";
|
||||||
|
import { api } from "../utils/api";
|
||||||
|
import { UploadContextModel } from "../types/contexts";
|
||||||
|
|
||||||
|
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
|
||||||
|
|
||||||
|
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔹 Upload any file → return public URL
|
||||||
|
*/
|
||||||
|
const uploadFile = async (file: File): Promise<string | null> => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const binary = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
const res = await api.post("/uploads", binary, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.url as string;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("File upload failed:", err);
|
||||||
|
setError(err.response?.data?.detail || "Failed to upload file");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UploadContext.Provider
|
||||||
|
value={{
|
||||||
|
uploadFile,
|
||||||
|
uploading,
|
||||||
|
error,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</UploadContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpload = (): UploadContextModel => {
|
||||||
|
const ctx = useContext(UploadContext);
|
||||||
|
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
29
src/blog/types/contexts.ts
Normal file
29
src/blog/types/contexts.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ArticleModel, AuthorModel } from "./models";
|
||||||
|
|
||||||
|
export interface ArticleContextModel {
|
||||||
|
articles: ArticleModel[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refreshArticles: () => Promise<void>;
|
||||||
|
updateArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
|
||||||
|
createArticle: (user: ArticleModel) => Promise<ArticleModel | 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadContextModel {
|
||||||
|
uploadFile: (file: File) => Promise<string | null>;
|
||||||
|
uploading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
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[];
|
||||||
|
}
|
||||||
50
src/blog/types/props.ts
Normal file
50
src/blog/types/props.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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 ArticleViewProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
onBack: () => void;
|
||||||
|
open_editor: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ArticleEditorProps {
|
||||||
|
article?: ArticleModel | null;
|
||||||
|
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
|
||||||
|
md12?: number, // default 12 (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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageUploadFieldProps {
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
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',
|
||||||
|
});
|
||||||
72
src/blog/types/views.ts
Normal file
72
src/blog/types/views.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export type View =
|
||||||
|
| "home"
|
||||||
|
| "login"
|
||||||
|
| "register"
|
||||||
|
| "article"
|
||||||
|
| "editor"
|
||||||
|
| "profile"
|
||||||
|
| "create";
|
||||||
|
|
||||||
|
export type ViewNode = {
|
||||||
|
parent: View | null;
|
||||||
|
children?: View[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIEW_TREE: Record<View, ViewNode> = {
|
||||||
|
home: {
|
||||||
|
parent: null,
|
||||||
|
children: ["login", "article", "profile", "create"],
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
parent: "home",
|
||||||
|
children: ["register"],
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
parent: "login",
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
parent: "home",
|
||||||
|
children: ["editor"],
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
parent: "article",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
parent: "home",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
parent: "home",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useViewRouter(setUI: any) {
|
||||||
|
const navigate = (view: View) => {
|
||||||
|
setUI((prev: any) => ({ ...prev, view }));
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto back logic from parent
|
||||||
|
const goBack = (view: View) => {
|
||||||
|
const parent = VIEW_TREE[view].parent;
|
||||||
|
if (parent) navigate(parent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openArticle = (i: number) => {
|
||||||
|
setUI({ selectedArticle: i, view: "article" });
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto child navigators from children[]
|
||||||
|
const navigateToChildren = (view: View) => {
|
||||||
|
const node = VIEW_TREE[view];
|
||||||
|
const funcs: Record<string, () => void> = {};
|
||||||
|
|
||||||
|
node.children?.forEach((child) => {
|
||||||
|
funcs[`open_${child}`] = () => navigate(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
return funcs;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { navigate, goBack, openArticle, navigateToChildren };
|
||||||
|
}
|
||||||
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,20 @@ import * as React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import Blog from './blog/Blog';
|
import Blog from './blog/Blog';
|
||||||
import { ArticleProvider } from './blog/providers/Article';
|
import { ArticleProvider } from './blog/providers/Article';
|
||||||
|
import { AuthProvider } from './blog/providers/Author';
|
||||||
|
import { UploadProvider } from "./blog/providers/Upload";
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<UploadProvider>
|
||||||
|
<AuthProvider>
|
||||||
<ArticleProvider>
|
<ArticleProvider>
|
||||||
<Blog />
|
<Blog />
|
||||||
</ArticleProvider>
|
</ArticleProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</UploadProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user