Compare commits
31 Commits
ec9b5c905a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 14b43cb3c5 | |||
| 8f398c35df | |||
| a7987ab922 | |||
| 7bdf84b6aa | |||
| 2b578fd12e | |||
| fe33dca630 | |||
| fa319e7450 | |||
| cb6125f3f9 | |||
| 0ed816e994 | |||
| 2dfbdb950a | |||
| fcc3ec16f9 | |||
| cff57f0980 | |||
| e90fab8c0b | |||
| 3aaf328511 | |||
| 635e99c183 | |||
| b8e4decfba | |||
| 459fa5855c | |||
| f52c4a5287 | |||
| 3a3f44c5b5 | |||
| 479ffb736c | |||
| 87bdafb6a3 | |||
| 383b424bdf | |||
| 0340e17467 | |||
| f15155d31c | |||
| c2e6daca13 | |||
| c0bcd0e3e4 | |||
| 333f931cff | |||
| 3960de3ecb | |||
| 763629faa1 | |||
| a7e3ed46cb | |||
| 4a8c59895e |
@@ -66,6 +66,8 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
API_BASE_URL:
|
API_BASE_URL:
|
||||||
from_secret: API_BASE_URL
|
from_secret: API_BASE_URL
|
||||||
|
AUTH_BASE_URL:
|
||||||
|
from_secret: AUTH_BASE_URL
|
||||||
volumes:
|
volumes:
|
||||||
- name: dockersock
|
- name: dockersock
|
||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
@@ -76,6 +78,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
docker build --network=host \
|
docker build --network=host \
|
||||||
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
||||||
|
--build-arg VITE_AUTH_BASE_URL="$AUTH_BASE_URL" \
|
||||||
-t apps/blog:$IMAGE_TAG \
|
-t apps/blog:$IMAGE_TAG \
|
||||||
-t apps/blog:latest \
|
-t apps/blog:latest \
|
||||||
/drone/src
|
/drone/src
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ COPY . .
|
|||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
ARG VITE_API_BASE_URL
|
ARG VITE_API_BASE_URL
|
||||||
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL npm run build
|
ARG VITE_AUTH_BASE_URL
|
||||||
|
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL VITE_AUTH_BASE_URL=$VITE_AUTH_BASE_URL npm run build
|
||||||
|
|
||||||
# Stage 2: Static file server (BusyBox)
|
# Stage 2: Static file server (BusyBox)
|
||||||
FROM busybox:latest
|
FROM busybox:latest
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.2.1",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useEffect } 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 Box from '@mui/material/Box';
|
||||||
@@ -14,146 +15,161 @@ import Register from './components/Register';
|
|||||||
import Profile from './components/Profile';
|
import Profile from './components/Profile';
|
||||||
import { useArticles } from './providers/Article';
|
import { useArticles } from './providers/Article';
|
||||||
import { useAuth } from './providers/Author';
|
import { useAuth } from './providers/Author';
|
||||||
|
import { View, useViewRouter } from "./types/views";
|
||||||
|
import { ArticleModel, ArticlesModel } from "./types/models";
|
||||||
|
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
|
||||||
|
|
||||||
type View = 'home' | 'login' | 'register' | 'article' | 'profile' | 'editor';
|
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();
|
const { articles, loading, error } = useArticles();
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
const [ui, setUI] = React.useState({
|
||||||
const [showLogin, setShowLogin] = React.useState(false);
|
selectedArticle: null as ArticleModel | null,
|
||||||
const [showRegister, setShowRegister] = React.useState(false);
|
view: "home" as View,
|
||||||
const [showProfile, setShowProfile] = React.useState(false);
|
});
|
||||||
const [showEditor, setShowEditor] = React.useState(false);
|
|
||||||
|
|
||||||
const handleSelectArticle = (index: number) => {
|
useEffect(() => {
|
||||||
setSelectedArticle(index);
|
if (loading) return;
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleShowLogin = () => {
|
|
||||||
setShowLogin(true);
|
|
||||||
setShowRegister(false);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleShowRegister = () => {
|
|
||||||
setShowRegister(true);
|
|
||||||
setShowLogin(false);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleHideAuth = () => {
|
|
||||||
setShowLogin(false);
|
|
||||||
setShowRegister(false);
|
|
||||||
};
|
|
||||||
const handleShowProfile = () => {
|
|
||||||
setShowProfile(true);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleHideProfile = () => {
|
|
||||||
setShowProfile(false);
|
|
||||||
};
|
|
||||||
const handleShowEditor = () => {
|
|
||||||
setShowEditor(true);
|
|
||||||
};
|
|
||||||
const handleHideEditor = () => {
|
|
||||||
setShowEditor(false);
|
|
||||||
};
|
|
||||||
const handleArticleViewBack = () => setSelectedArticle(null);
|
|
||||||
const handleArticleEditorBack = () => {
|
|
||||||
handleHideEditor()
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// derive a single source of truth for view
|
const path = window.location.pathname;
|
||||||
const view: View = React.useMemo(() => {
|
const parts = path.split('/').filter(Boolean);
|
||||||
if (selectedArticle !== null && !showEditor) return 'article';
|
|
||||||
if (showRegister) return 'register';
|
|
||||||
if (showLogin) return 'login';
|
|
||||||
if (showProfile) return 'profile';
|
|
||||||
if (showEditor) return 'editor';
|
|
||||||
return 'home';
|
|
||||||
}, [selectedArticle, showLogin, showRegister, showProfile, showEditor]);
|
|
||||||
|
|
||||||
// render function keeps JSX tidy
|
if (parts[0] === 'articles' && parts[1]) {
|
||||||
const renderView = () => {
|
const id = parts[1];
|
||||||
switch (view) {
|
const article = articles.readById(id);
|
||||||
case 'register':
|
|
||||||
return <Register onBack={handleHideAuth} />;
|
|
||||||
case 'login':
|
|
||||||
return (
|
|
||||||
<Login
|
|
||||||
onBack={handleHideAuth}
|
|
||||||
onRegister={() => {
|
|
||||||
handleShowRegister();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'profile':
|
|
||||||
return (
|
|
||||||
<Profile
|
|
||||||
onBack={handleHideProfile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'article':
|
|
||||||
if (selectedArticle == null || !articles[selectedArticle]) return null;
|
|
||||||
return <ArticleView
|
|
||||||
article={articles[selectedArticle]}
|
|
||||||
onBack={handleArticleViewBack}
|
|
||||||
onEdit={handleShowEditor}
|
|
||||||
/>;
|
|
||||||
case 'editor':
|
|
||||||
if (selectedArticle == null || !articles[selectedArticle])
|
|
||||||
return <ArticleEditor
|
|
||||||
onBack={handleArticleEditorBack}
|
|
||||||
/>
|
|
||||||
return <ArticleEditor
|
|
||||||
article={articles[selectedArticle] || null}
|
|
||||||
onBack={handleArticleEditorBack}
|
|
||||||
/>
|
|
||||||
case 'home':
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
|
|
||||||
{!currentUser ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleShowLogin}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleShowProfile}
|
|
||||||
>
|
|
||||||
{currentUser.username}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleShowEditor}
|
|
||||||
>
|
|
||||||
New Article
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
if (article) {
|
||||||
<Latest
|
setUI({
|
||||||
articles={articles}
|
selectedArticle: article,
|
||||||
onSelectArticle={handleSelectArticle}
|
view: 'article',
|
||||||
onLoadMore={async () => {}}
|
});
|
||||||
/>
|
}
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
goBack,
|
||||||
|
navigateToChildren,
|
||||||
|
openArticle,
|
||||||
|
} = useViewRouter(setUI);
|
||||||
|
|
||||||
|
type RouterContext = {
|
||||||
|
ui: any;
|
||||||
|
articles: ArticlesModel;
|
||||||
|
currentUser: any;
|
||||||
|
openArticle: (article: ArticleModel) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewComponentEntry<P> = {
|
||||||
|
component: React.ComponentType<P>;
|
||||||
|
extraProps?: (ctx: RouterContext) => Partial<P>;
|
||||||
|
navigationMap?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
|
||||||
|
home: {
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
|
||||||
|
login: {
|
||||||
|
component: Login,
|
||||||
|
navigationMap: {
|
||||||
|
open_register: 'onRegister',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
register: {
|
||||||
|
component: Register,
|
||||||
|
},
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
component: Profile,
|
||||||
|
},
|
||||||
|
|
||||||
|
article: {
|
||||||
|
component: ArticleView,
|
||||||
|
navigationMap: {
|
||||||
|
open_editor: 'onEdit',
|
||||||
|
},
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
article: articles.readById(ui.selectedArticle._id),
|
||||||
|
}) satisfies Partial<ArticleViewProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id) : null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
create: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: () => ({
|
||||||
|
article: null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderView = () => {
|
||||||
|
const entry = VIEW_COMPONENTS[ui.view];
|
||||||
|
const navigationMap= entry['navigationMap'] || {}
|
||||||
|
const ViewComponent = entry.component;
|
||||||
|
|
||||||
|
const childNav = navigateToChildren(
|
||||||
|
ui.view,
|
||||||
|
navigationMap
|
||||||
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -210,13 +226,13 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
my: 4,
|
my: 4,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
pb: view === 'home' ? 24 : 0,
|
pb: ui.view === 'home' ? 24 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderView()}
|
{renderView()}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{view === 'home' && (
|
{ui.view === 'home' && (
|
||||||
<Box
|
<Box
|
||||||
component="footer"
|
component="footer"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export default function ArticleView({
|
|||||||
title,
|
title,
|
||||||
tag,
|
tag,
|
||||||
img,
|
img,
|
||||||
|
description,
|
||||||
content,
|
content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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 EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||||
import { ArticleMeta } from "../ArticleMeta";
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
import { ArticleProps } from '../../types/props';
|
import { ArticleViewProps } from '../../types/props';
|
||||||
|
import {useAuth} from "../../providers/Author";
|
||||||
|
|
||||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
@@ -28,24 +29,29 @@ export default function ArticleView({
|
|||||||
article,
|
article,
|
||||||
onBack,
|
onBack,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: ArticleProps) {
|
}: ArticleViewProps) {
|
||||||
|
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
<Chip
|
{currentUser && (
|
||||||
label={article.tag}
|
<IconButton onClick={onEdit}>
|
||||||
variant="outlined"
|
<EditRoundedIcon />
|
||||||
color="primary"
|
</IconButton>
|
||||||
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
)}
|
||||||
/>
|
</Box>
|
||||||
|
|
||||||
<IconButton onClick={onEdit} sx={{ mb: 2 }}>
|
|
||||||
<EditRoundedIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
||||||
{article.title}
|
{article.title}
|
||||||
@@ -55,7 +61,21 @@ export default function ArticleView({
|
|||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
<CoverImage src={article.img} alt={article.title} />
|
<Chip
|
||||||
|
label={article.tag}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CoverImage
|
||||||
|
src={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
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(article)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,7 +16,7 @@ export default function ArticleCardSize2({
|
|||||||
return (
|
return (
|
||||||
<StyledCard
|
<StyledCard
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => onSelectArticle(index)}
|
onClick={() => onSelectArticle(article)}
|
||||||
onFocus={() => onFocus(index)}
|
onFocus={() => onFocus(index)}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function ArticleCardSize4({
|
|||||||
return (
|
return (
|
||||||
<StyledCard
|
<StyledCard
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => onSelectArticle(index)}
|
onClick={() => onSelectArticle(article)}
|
||||||
onFocus={() => onFocus(index)}
|
onFocus={() => onFocus(index)}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -24,7 +24,7 @@ export default function ArticleCardSize4({
|
|||||||
>
|
>
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
alt="green iguana"
|
alt={article.title}
|
||||||
image={(
|
image={(
|
||||||
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
"/" +
|
"/" +
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function ArticleCardSize6({
|
|||||||
return (
|
return (
|
||||||
<StyledCard
|
<StyledCard
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => onSelectArticle(index)}
|
onClick={() => onSelectArticle(article)}
|
||||||
onFocus={() => onFocus(index)}
|
onFocus={() => onFocus(index)}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Grid, Box } from '@mui/material';
|
import { Grid, Box } from '@mui/material';
|
||||||
|
import ArticleCardSize12 from './ArticleCardSize12';
|
||||||
import ArticleCardSize6 from './ArticleCardSize6';
|
import ArticleCardSize6 from './ArticleCardSize6';
|
||||||
import ArticleCardSize4 from './ArticleCardSize4';
|
import ArticleCardSize4 from './ArticleCardSize4';
|
||||||
import ArticleCardSize2 from './ArticleCardSize2';
|
import ArticleCardSize2 from './ArticleCardSize2';
|
||||||
@@ -10,6 +11,7 @@ export default function ArticleCardsGrid({
|
|||||||
articles,
|
articles,
|
||||||
onSelectArticle,
|
onSelectArticle,
|
||||||
xs = 12,
|
xs = 12,
|
||||||
|
md12 = 12,
|
||||||
md6 = 6,
|
md6 = 6,
|
||||||
md4 = 4,
|
md4 = 4,
|
||||||
nested = 2,
|
nested = 2,
|
||||||
@@ -30,8 +32,9 @@ export default function ArticleCardsGrid({
|
|||||||
setFocusedCardIndex(null);
|
setFocusedCardIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCard = (article: ArticleModel, index: number, type: '6' | '4' | '2' = '6') => {
|
const renderCard = (article: ArticleModel, index: number, type: '12' | '6' | '4' | '2' = '12') => {
|
||||||
const CardComponent =
|
const CardComponent =
|
||||||
|
type === '12' ? ArticleCardSize12 :
|
||||||
type === '6' ? ArticleCardSize6 :
|
type === '6' ? ArticleCardSize6 :
|
||||||
type === '4' ? ArticleCardSize4 :
|
type === '4' ? ArticleCardSize4 :
|
||||||
ArticleCardSize2;
|
ArticleCardSize2;
|
||||||
@@ -51,6 +54,17 @@ export default function ArticleCardsGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2} columns={12}>
|
<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 ---- */}
|
{/* ---- 2 articles: 6 | 6 ---- */}
|
||||||
{count === 2 && (
|
{count === 2 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,99 +1,19 @@
|
|||||||
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 Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { styled } from '@mui/material/styles';
|
|
||||||
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { LatestProps } from "../types/props";
|
import { LatestProps } from "../types/props";
|
||||||
|
import { StyledTypography, TitleTypography } from "../types/styles";
|
||||||
|
import { ArticleMeta } from "./ArticleMeta";
|
||||||
import Fade from '@mui/material/Fade';
|
import Fade from '@mui/material/Fade';
|
||||||
|
|
||||||
|
export default function Latest({
|
||||||
const StyledTypography = styled(Typography)({
|
articles,
|
||||||
display: '-webkit-box',
|
onSelectArticle,
|
||||||
WebkitBoxOrient: 'vertical',
|
onLoadMore
|
||||||
WebkitLineClamp: 2,
|
}: LatestProps) {
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
});
|
|
||||||
|
|
||||||
const TitleTypography = styled(Typography)(({ theme }) => ({
|
|
||||||
position: 'relative',
|
|
||||||
textDecoration: 'none',
|
|
||||||
'&:hover': { cursor: 'pointer' },
|
|
||||||
'& .arrow': {
|
|
||||||
visibility: 'hidden',
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
},
|
|
||||||
'&:hover .arrow': {
|
|
||||||
visibility: 'visible',
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
'&:focus-visible': {
|
|
||||||
outline: '3px solid',
|
|
||||||
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
|
|
||||||
outlineOffset: '3px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
},
|
|
||||||
'&::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
width: 0,
|
|
||||||
height: '1px',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
backgroundColor: (theme.vars || theme).palette.text.primary,
|
|
||||||
opacity: 0.3,
|
|
||||||
transition: 'width 0.3s ease, opacity 0.3s ease',
|
|
||||||
},
|
|
||||||
'&:hover::before': {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 2,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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((a) => a.name).join(', ')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption">Recently Updated</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
|
|
||||||
const [visibleCount, setVisibleCount] = React.useState(2);
|
const [visibleCount, setVisibleCount] = React.useState(2);
|
||||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
const [animating, setAnimating] = React.useState(false);
|
const [animating, setAnimating] = React.useState(false);
|
||||||
@@ -182,7 +102,7 @@ export default function Latest({ articles, onSelectArticle, onLoadMore }: Latest
|
|||||||
{article.description}
|
{article.description}
|
||||||
</StyledTypography>
|
</StyledTypography>
|
||||||
|
|
||||||
<Author authors={article.authors} />
|
<ArticleMeta article={article} />
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import * as React from 'react';
|
|||||||
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
|
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import { useAuth } from '../providers/Author';
|
import { useAuth } from '../providers/Author';
|
||||||
|
import { LoginProps } from '../types/props';
|
||||||
|
|
||||||
interface LoginProps {
|
export default function Login({
|
||||||
onBack: () => void;
|
onBack,
|
||||||
onRegister: () => void;
|
onRegister
|
||||||
}
|
}: LoginProps) {
|
||||||
|
|
||||||
export default function Login({ onBack, onRegister }: LoginProps) {
|
|
||||||
const { login, loading, error, currentUser } = useAuth();
|
const { login, loading, error, currentUser } = useAuth();
|
||||||
const [username, setUsername] = React.useState('');
|
const [username, setUsername] = React.useState('');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import OutlinedInput from '@mui/material/OutlinedInput';
|
|||||||
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 { ArticleModel } from "../types/models";
|
import {ArticlesModel, createArticlesModelObject} from "../types/models";
|
||||||
|
import { MainContentProps } from "../types/props";
|
||||||
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
|
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
|
||||||
|
|
||||||
export function Search() {
|
export function Search() {
|
||||||
@@ -36,12 +37,9 @@ export function Search() {
|
|||||||
export default function MainContent({
|
export default function MainContent({
|
||||||
articles,
|
articles,
|
||||||
onSelectArticle,
|
onSelectArticle,
|
||||||
}: {
|
}: MainContentProps) {
|
||||||
articles: ArticleModel[];
|
|
||||||
onSelectArticle: (index: number) => void;
|
|
||||||
}) {
|
|
||||||
|
|
||||||
const [visibleArticles, setVisibleArticles] = React.useState<ArticleModel[]>(articles);
|
const [visibleArticles, setVisibleArticles] = React.useState<ArticlesModel>(articles);
|
||||||
const [activeTag, setActiveTag] = React.useState<string>('all');
|
const [activeTag, setActiveTag] = React.useState<string>('all');
|
||||||
|
|
||||||
const filterArticlesByTag = (tag: string) => {
|
const filterArticlesByTag = (tag: string) => {
|
||||||
@@ -60,11 +58,11 @@ export default function MainContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔵 Filter by selected tag
|
// 🔵 Filter by selected tag
|
||||||
const filtered = articles.filter((article) => article.tag === tag);
|
const filtered = articles.articlesList.filter((article) => article.tag === tag);
|
||||||
console.log('👀 All Articles:', articles);
|
console.log('👀 All Articles:', articles);
|
||||||
console.log(`👀 Filtered (${tag}):`, filtered);
|
console.log(`👀 Filtered (${tag}):`, filtered);
|
||||||
|
|
||||||
setVisibleArticles(filtered);
|
setVisibleArticles(createArticlesModelObject(filtered));
|
||||||
setActiveTag(tag);
|
setActiveTag(tag);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
|||||||
import { useAuth } from '../providers/Author';
|
import { useAuth } from '../providers/Author';
|
||||||
import { useUpload } from "../providers/Upload";
|
import { useUpload } from "../providers/Upload";
|
||||||
import ImageUploadField from './ImageUploadField';
|
import ImageUploadField from './ImageUploadField';
|
||||||
|
import { ProfileProps } from '../types/props';
|
||||||
|
|
||||||
interface ProfileProps {
|
export default function Profile({
|
||||||
onBack: () => void;
|
onBack
|
||||||
}
|
}: ProfileProps) {
|
||||||
|
|
||||||
export default function Profile({ onBack }: ProfileProps) {
|
|
||||||
const { currentUser, loading, error, logout, updateProfile } = useAuth();
|
const { currentUser, loading, error, logout, updateProfile } = useAuth();
|
||||||
const { uploadFile } = useUpload();
|
const { uploadFile } = useUpload();
|
||||||
const [formData, setFormData] = React.useState({
|
const [formData, setFormData] = React.useState({
|
||||||
@@ -134,6 +133,7 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
label="Username"
|
label="Username"
|
||||||
name="username"
|
name="username"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
|
disabled={true}
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ import * as React from 'react';
|
|||||||
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Alert, } from '@mui/material';
|
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Alert, } from '@mui/material';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import { useAuth } from '../providers/Author';
|
import { useAuth } from '../providers/Author';
|
||||||
|
import { RegisterProps } from '../types/props';
|
||||||
|
|
||||||
interface RegisterProps {
|
export default function Register({
|
||||||
onBack: () => void;
|
onBack
|
||||||
}
|
}: RegisterProps) {
|
||||||
|
|
||||||
export default function Register({ onBack }: RegisterProps) {
|
|
||||||
const { register, loading, error, currentUser } = useAuth();
|
const { register, loading, error, currentUser } = useAuth();
|
||||||
const [username, setUsername] = React.useState('');
|
const [username, setUsername] = React.useState('');
|
||||||
const [password1, setPassword1] = React.useState('');
|
const [password1, setPassword1] = React.useState('');
|
||||||
const [password2, setPassword2] = React.useState('');
|
const [password2, setPassword2] = React.useState('');
|
||||||
const [localError, setLocalError] = React.useState<string | null>(null);
|
const [localError, setLocalError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { ArticleModel } from '../types/models';
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
ArticlesModel,
|
||||||
|
createArticlesModelObject,
|
||||||
|
} from '../types/models';
|
||||||
import { ArticleContextModel } from '../types/contexts';
|
import { ArticleContextModel } from '../types/contexts';
|
||||||
import { useAuth } from './Author';
|
import { useAuth } from './Author';
|
||||||
|
|
||||||
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
|
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
|
||||||
|
|
||||||
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
const [articles, setArticles] = useState<ArticlesModel>(createArticlesModelObject());
|
||||||
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 } = useAuth();
|
const { token, currentUser } = useAuth();
|
||||||
|
|
||||||
/** 🔹 Author IDs must be strings for API, so we normalize here */
|
/** 🔹 Author IDs must be strings for API, so we normalize here */
|
||||||
const normalizeArticleForApi = (article: Partial<ArticleModel>) => ({
|
const normalizeArticleForApi = (article: Partial<ArticleModel>) => {
|
||||||
...article,
|
// Extract existing authors as a list of IDs (string[])
|
||||||
authors: (article.authors ?? []).map(a =>
|
const existingIds = (article.authors ?? []).map(a =>
|
||||||
a._id
|
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) */
|
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
@@ -28,7 +42,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
|
|
||||||
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 100 } });
|
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 100 } });
|
||||||
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
|
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
|
||||||
setArticles(formatted);
|
setArticles(prev => prev.refresh(formatted));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch articles:', err);
|
console.error('Failed to fetch articles:', err);
|
||||||
setError(err.response?.data?.detail || 'Failed to fetch articles');
|
setError(err.response?.data?.detail || 'Failed to fetch articles');
|
||||||
@@ -43,6 +57,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
console.error('updateArticle called without _id');
|
console.error('updateArticle called without _id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!currentUser) {
|
||||||
|
console.error('updateArticle called without logged in user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedArticleData = normalizeArticleForApi(articleData);
|
const normalizedArticleData = normalizeArticleForApi(articleData);
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +68,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
|
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
|
||||||
|
setArticles(prev => {
|
||||||
|
prev.update(res.data);
|
||||||
|
return { ...prev };
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Article update failed:', err);
|
console.error('Article update failed:', err);
|
||||||
@@ -72,6 +94,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
|
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
|
||||||
|
setArticles(prev => {
|
||||||
|
prev.create(res.data);
|
||||||
|
return { ...prev };
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Article create failed:', err);
|
console.error('Article create failed:', err);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||||
import { api } from '../utils/api';
|
import { api, auth } from '../utils/api';
|
||||||
import { AuthorModel } from '../types/models';
|
import { AuthorModel } from '../types/models';
|
||||||
import { AuthContextModel } from '../types/contexts';
|
import { AuthContextModel } from '../types/contexts';
|
||||||
|
|
||||||
@@ -18,7 +18,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.post('/auth/register', { username, password });
|
const res = await auth.post('/register', { username, password });
|
||||||
|
|
||||||
|
// auto-login
|
||||||
|
// await login(username, password);
|
||||||
|
|
||||||
|
// now create author
|
||||||
|
await api.post('/authors', { name: null, avatar: null });
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Registration failed:', err);
|
console.error('Registration failed:', err);
|
||||||
@@ -34,7 +41,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.post('/auth/login', { username, password });
|
const res = await auth.post('/login', { username, password });
|
||||||
const { access_token, user } = res.data;
|
const { access_token, user } = res.data;
|
||||||
|
|
||||||
if (access_token) {
|
if (access_token) {
|
||||||
@@ -99,9 +106,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
const fetchCurrentUser = async () => {
|
const fetchCurrentUser = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const me = await api.get<{ _id: string; username: string; email: string }>('/auth/me');
|
const me = await auth.get('/me');
|
||||||
|
|
||||||
const author = await api.get<AuthorModel>(`/authors/${me.data._id}`);
|
const author = await api.get<AuthorModel>(`/authors/me`);
|
||||||
|
|
||||||
const fullUser = { ...me.data, ...author.data };
|
const fullUser = { ...me.data, ...author.data };
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { ArticleModel, AuthorModel } from "./models";
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
ArticlesModel,
|
||||||
|
AuthorModel
|
||||||
|
} from "./models";
|
||||||
|
|
||||||
export interface ArticleContextModel {
|
export interface ArticleContextModel {
|
||||||
articles: ArticleModel[];
|
articles: ArticlesModel;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refreshArticles: () => Promise<void>;
|
refreshArticles: () => Promise<void>;
|
||||||
updateArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
|
updateArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
|
||||||
createArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
|
createArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthContextModel {
|
export interface AuthContextModel {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
createInList, readInList, updateInList, deleteInList,
|
||||||
|
createById, readById, updateById, deleteById
|
||||||
|
} from "../utils/articles";
|
||||||
|
|
||||||
|
|
||||||
export interface AuthorModel {
|
export interface AuthorModel {
|
||||||
// meta fields
|
// meta fields
|
||||||
_id?: string | null;
|
_id?: string | null;
|
||||||
@@ -28,3 +34,82 @@ export interface ArticleModel {
|
|||||||
// ref fields
|
// ref fields
|
||||||
authors: AuthorModel[];
|
authors: AuthorModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArticlesModel {
|
||||||
|
articlesList: ArticleModel[];
|
||||||
|
articlesById: Record<string, ArticleModel>;
|
||||||
|
// articlesByTag: Record<string, ArticleModel[]>;
|
||||||
|
// articlesByAuthor: Record<string, ArticleModel[]>;
|
||||||
|
|
||||||
|
length: number;
|
||||||
|
slice(start: number, end?: number): ArticleModel[];
|
||||||
|
|
||||||
|
refresh(list: ArticleModel[]): ArticlesModel;
|
||||||
|
create(a: ArticleModel): ArticlesModel;
|
||||||
|
readByIndex(index: number): ArticleModel | undefined;
|
||||||
|
readById(id: string): ArticleModel | undefined;
|
||||||
|
update(a: ArticleModel): ArticlesModel;
|
||||||
|
delete(id: string): ArticlesModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- FACTORY ----------
|
||||||
|
export function createArticlesModelObject(
|
||||||
|
articles: ArticleModel[] = []
|
||||||
|
): ArticlesModel {
|
||||||
|
const initialMap: Record<string, ArticleModel> = {};
|
||||||
|
for (const a of articles) {
|
||||||
|
if (a._id) initialMap[a._id] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
articlesList: articles,
|
||||||
|
articlesById: initialMap,
|
||||||
|
|
||||||
|
// --- computed property ---
|
||||||
|
get length() {
|
||||||
|
return this.articlesList.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- array-like slice ---
|
||||||
|
slice(start: number, end?: number) {
|
||||||
|
return this.articlesList.slice(start, end);
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh(list) {
|
||||||
|
this.articlesList = list;
|
||||||
|
|
||||||
|
const map: Record<string, ArticleModel> = {};
|
||||||
|
for (const a of list) {
|
||||||
|
if (a._id) map[a._id] = a;
|
||||||
|
}
|
||||||
|
this.articlesById = map;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
create(a) {
|
||||||
|
this.articlesList = createInList(this.articlesList, a);
|
||||||
|
this.articlesById = createById(this.articlesById, a);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
readByIndex(index) {
|
||||||
|
return readInList(this.articlesList, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
readById(id) {
|
||||||
|
return readById(this.articlesById, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(a) {
|
||||||
|
this.articlesList = updateInList(this.articlesList, a);
|
||||||
|
this.articlesById = updateById(this.articlesById, a);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
this.articlesList = deleteInList(this.articlesList, id);
|
||||||
|
this.articlesById = deleteById(this.articlesById, id);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
import { ArticleModel } from "./models";
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
ArticlesModel,
|
||||||
|
} from "./models";
|
||||||
|
|
||||||
export interface LatestProps {
|
export interface LatestProps {
|
||||||
articles: ArticleModel[];
|
articles: ArticlesModel;
|
||||||
onSelectArticle?: (index: number) => void;
|
onSelectArticle?: (index: number) => void;
|
||||||
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleProps {
|
export interface LoginProps {
|
||||||
|
onBack: () => void;
|
||||||
|
onRegister: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MainContentProps {
|
||||||
|
articles: ArticlesModel;
|
||||||
|
onSelectArticle: (index: ArticleModel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleViewProps {
|
||||||
article: ArticleModel;
|
article: ArticleModel;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleEditorProps {
|
export interface ArticleEditorProps {
|
||||||
article?: ArticleModel;
|
article?: ArticleModel | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,15 +46,16 @@ export interface ArticleCardProps {
|
|||||||
article: ArticleModel;
|
article: ArticleModel;
|
||||||
index: number;
|
index: number;
|
||||||
focusedCardIndex: number | null;
|
focusedCardIndex: number | null;
|
||||||
onSelectArticle: (index: number) => void;
|
onSelectArticle: (index: ArticleModel) => void;
|
||||||
onFocus: (index: number) => void;
|
onFocus: (index: number) => void;
|
||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleGridProps {
|
export interface ArticleGridProps {
|
||||||
articles: ArticleModel[];
|
articles: ArticlesModel;
|
||||||
onSelectArticle: (index: number) => void;
|
onSelectArticle: (index: ArticleModel) => void;
|
||||||
xs?: number; // default 12 for mobile full-width
|
xs?: number; // default 12 for mobile full-width
|
||||||
|
md12?: number, // default 12 (full-width)
|
||||||
md6?: number; // default 6 (half-width)
|
md6?: number; // default 6 (half-width)
|
||||||
md4?: number; // default 4 (third-width)
|
md4?: number; // default 4 (third-width)
|
||||||
nested?: 1 | 2; // number of stacked cards in a nested column
|
nested?: 1 | 2; // number of stacked cards in a nested column
|
||||||
|
|||||||
@@ -37,4 +37,41 @@ export const StyledTypography = styled(Typography)({
|
|||||||
WebkitLineClamp: 2,
|
WebkitLineClamp: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TitleTypography = styled(Typography)(({ theme }) => ({
|
||||||
|
position: 'relative',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': { cursor: 'pointer' },
|
||||||
|
'& .arrow': {
|
||||||
|
visibility: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
},
|
||||||
|
'&:hover .arrow': {
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '3px solid',
|
||||||
|
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
|
||||||
|
outlineOffset: '3px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
},
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
width: 0,
|
||||||
|
height: '1px',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
backgroundColor: (theme.vars || theme).palette.text.primary,
|
||||||
|
opacity: 0.3,
|
||||||
|
transition: 'width 0.3s ease, opacity 0.3s ease',
|
||||||
|
},
|
||||||
|
'&:hover::before': {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
113
src/blog/types/views.ts
Normal file
113
src/blog/types/views.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {ArticleModel} from "./models";
|
||||||
|
|
||||||
|
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 const VIEW_URL: Record<View, (ui?: any) => string> = {
|
||||||
|
home: () => "/",
|
||||||
|
login: () => "/login",
|
||||||
|
register: () => "/register",
|
||||||
|
profile: () => "/profile",
|
||||||
|
create: () => "/create",
|
||||||
|
article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`,
|
||||||
|
editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useViewRouter(setUI: any) {
|
||||||
|
const navigate = (
|
||||||
|
view: View,
|
||||||
|
nextState?: any
|
||||||
|
) => {
|
||||||
|
setUI((prev: any) => {
|
||||||
|
const newState = { ...prev, ...nextState, view };
|
||||||
|
|
||||||
|
// update URL
|
||||||
|
const url = VIEW_URL[view](newState);
|
||||||
|
window.history.pushState(newState, "", url);
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = (article: ArticleModel) => {
|
||||||
|
setUI((prev: any) => {
|
||||||
|
const newState = {
|
||||||
|
...prev,
|
||||||
|
selectedArticle: article,
|
||||||
|
view: "article",
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `/articles/${article._id}`;
|
||||||
|
window.history.pushState(newState, "", url);
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto child navigators from children[]
|
||||||
|
const navigateToChildren = (
|
||||||
|
view: View,
|
||||||
|
navigationMap?: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
const node = VIEW_TREE[view];
|
||||||
|
const funcs: Record<string, () => void> = {};
|
||||||
|
|
||||||
|
node.children?.forEach((child) => {
|
||||||
|
const funcName = `open_${child}`;
|
||||||
|
const customFuncName = navigationMap?.[funcName];
|
||||||
|
funcs[funcName] = () => navigate(child);
|
||||||
|
if (customFuncName) funcs[customFuncName] = () => navigate(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
return funcs;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { navigate, goBack, openArticle, navigateToChildren };
|
||||||
|
}
|
||||||
@@ -1,8 +1,42 @@
|
|||||||
// src/utils/api.ts
|
// src/utils/api.ts
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
|
//------------------------------------------------------
|
||||||
|
// COMMON TOKEN ATTACHMENT LOGIC
|
||||||
|
//------------------------------------------------------
|
||||||
|
const attachToken = (config: any) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthError = (error: any) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
console.warn('Token expired or invalid. Logging out...');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
// Optional: eventBus, redirect, logout callback
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
//------------------------------------------------------
|
||||||
|
// AUTH SERVICE CLIENT
|
||||||
|
//------------------------------------------------------
|
||||||
|
export const auth = axios.create({
|
||||||
|
baseURL: AUTH_BASE,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//------------------------------------------------------
|
||||||
|
// BLOG SERVICE CLIENT
|
||||||
|
//------------------------------------------------------
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: API_BASE,
|
baseURL: API_BASE,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -10,24 +44,10 @@ export const api = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔹 Attach token from localStorage before each request
|
// Attach token + 401 handling
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use(attachToken);
|
||||||
const token = localStorage.getItem('token');
|
api.interceptors.response.use((res) => res, handleAuthError);
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🔹 Handle expired or invalid tokens globally
|
// Auth service ALSO needs token for /me, /logout, /introspect
|
||||||
api.interceptors.response.use(
|
auth.interceptors.request.use(attachToken);
|
||||||
(response) => response,
|
auth.interceptors.response.use((res) => res, handleAuthError);
|
||||||
(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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
74
src/blog/utils/articles.ts
Normal file
74
src/blog/utils/articles.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
} from "../types/models";
|
||||||
|
|
||||||
|
export function createInList(list: ArticleModel[], a: ArticleModel) {
|
||||||
|
return [...list, a];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readInList(list: ArticleModel[], index: number) {
|
||||||
|
if (index < 0 || index >= list.length) {
|
||||||
|
// Soft fallback
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return list[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInList(list: ArticleModel[], a: ArticleModel) {
|
||||||
|
return list.map(x => (x._id === a._id ? a : x));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteInList(list: ArticleModel[], id: string) {
|
||||||
|
return list.filter(x => x._id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map helpers
|
||||||
|
export function createById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
a: ArticleModel
|
||||||
|
) {
|
||||||
|
if (!a._id) {
|
||||||
|
// Soft mode: ignore create, return unchanged
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map[a._id]) {
|
||||||
|
// Soft mode: do not replace existing
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...map, [a._id]: a };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return map[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
a: ArticleModel
|
||||||
|
) {
|
||||||
|
if (!a._id) {
|
||||||
|
// Cannot update without ID
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map[a._id]) {
|
||||||
|
// ID does not exist → soft mode: do nothing
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...map, [a._id]: a };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
const { [id]: _, ...rest } = map;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
16
src/main.jsx
16
src/main.jsx
@@ -9,13 +9,11 @@ const rootElement = document.getElementById('root');
|
|||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<UploadProvider>
|
||||||
<UploadProvider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<ArticleProvider>
|
||||||
<ArticleProvider>
|
<Blog />
|
||||||
<Blog />
|
</ArticleProvider>
|
||||||
</ArticleProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</UploadProvider>,
|
||||||
</UploadProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL: string;
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
readonly VITE_AUTH_BASE_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user