Compare commits
16 Commits
d29efe53e0
...
3e1ec9a3ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e1ec9a3ed | |||
| 3cac047709 | |||
| 1f21ab38fc | |||
| 1f5066a661 | |||
| 6798b64431 | |||
| 7fa61e6c2e | |||
| b09900f8ec | |||
| fc39d832c1 | |||
| 74cae4e4ea | |||
| 08c20c2613 | |||
| 7fece6f8f9 | |||
| e75beaac48 | |||
| 6d951b9ab5 | |||
| 6abdd443e0 | |||
| e9c654e138 | |||
| eddb251e4d |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setShowProfile(true)}
|
onClick={() => setShowProfile(true)}
|
||||||
>
|
>
|
||||||
Profile
|
{currentUser.username}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 { ArticleMeta } from "./ArticleMeta";
|
||||||
import { ArticleProps } from '../types/props';
|
import { ArticleProps } from '../types/props';
|
||||||
|
|
||||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
@@ -44,21 +45,7 @@ export default function Article({
|
|||||||
{article.title}
|
{article.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
|
<ArticleMeta article={article} />
|
||||||
<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">
|
|
||||||
{new Date(article.created_at).toLocaleString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
|||||||
52
src/blog/components/ArticleCards/ArticleCardSize2.tsx
Normal file
52
src/blog/components/ArticleCards/ArticleCardSize2.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize2({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(index)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<StyledCardContent
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
gutterBottom
|
||||||
|
>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</div>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
48
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CardMedia, Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize4({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(index)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
alt="green iguana"
|
||||||
|
image={article.img}
|
||||||
|
sx={{
|
||||||
|
height: { sm: 'auto', md: '50%' },
|
||||||
|
aspectRatio: { sm: '16 / 9', md: '' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledCardContent>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
49
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CardMedia, Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize6({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(index)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
alt={article.title}
|
||||||
|
image={article.img}
|
||||||
|
sx={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledCardContent>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
138
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
138
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Grid, Box } from '@mui/material';
|
||||||
|
import ArticleCardSize6 from './ArticleCardSize6';
|
||||||
|
import ArticleCardSize4 from './ArticleCardSize4';
|
||||||
|
import ArticleCardSize2 from './ArticleCardSize2';
|
||||||
|
import { ArticleModel } from "../../types/models";
|
||||||
|
import { ArticleGridProps } from "../../types/props";
|
||||||
|
|
||||||
|
export default function ArticleCardsGrid({
|
||||||
|
articles,
|
||||||
|
onSelectArticle,
|
||||||
|
xs = 12,
|
||||||
|
md6 = 6,
|
||||||
|
md4 = 4,
|
||||||
|
nested = 2,
|
||||||
|
}: ArticleGridProps ) {
|
||||||
|
|
||||||
|
const visibleArticles = articles.slice(0, 6)
|
||||||
|
const count = visibleArticles.length;
|
||||||
|
|
||||||
|
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = (index: number) => {
|
||||||
|
setFocusedCardIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setFocusedCardIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCard = (article: ArticleModel, index: number, type: '6' | '4' | '2' = '6') => {
|
||||||
|
const CardComponent =
|
||||||
|
type === '6' ? ArticleCardSize6 :
|
||||||
|
type === '4' ? ArticleCardSize4 :
|
||||||
|
ArticleCardSize2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardComponent
|
||||||
|
key={index}
|
||||||
|
article={article}
|
||||||
|
index={index}
|
||||||
|
focusedCardIndex={focusedCardIndex}
|
||||||
|
onSelectArticle={onSelectArticle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2} columns={12}>
|
||||||
|
{/* ---- 2 articles: 6 | 6 ---- */}
|
||||||
|
{count === 2 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 3 articles: 4 | 4 | 4 ---- */}
|
||||||
|
{count === 3 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(a, i, '4')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 4 articles: (6|6) + (6|6) ---- */}
|
||||||
|
{count === 4 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 5 articles: (6|6) + (4|4|4) ---- */}
|
||||||
|
{count === 5 && (
|
||||||
|
<>
|
||||||
|
{/* Row 1: 2 x size6 */}
|
||||||
|
{visibleArticles.slice(0, 2).map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 2: 3 x size4 */}
|
||||||
|
{visibleArticles.slice(2).map((a, i) => (
|
||||||
|
<Grid key={i + 2} size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(a, i + 2, '4')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 6 articles: (6|6) + (4|2x2|4) ---- */}
|
||||||
|
{count === 6 && (
|
||||||
|
<>
|
||||||
|
{/* Top row: 2 x size6 */}
|
||||||
|
{visibleArticles.slice(0, 2).map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Bottom row: 4 + 2x2 + 4 */}
|
||||||
|
<Grid size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(visibleArticles[2], 2, '4')}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs, md: md4 }}>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}
|
||||||
|
>
|
||||||
|
{visibleArticles.slice(3, 3 + nested).map((a, i) =>
|
||||||
|
renderCard(a, i + 3, '2')
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(visibleArticles[5], 5, '4')}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/blog/components/ArticleMeta.tsx
Normal file
53
src/blog/components/ArticleMeta.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import AvatarGroup from "@mui/material/AvatarGroup";
|
||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import {Typography} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { ArticleMetaProps } from "../types/props";
|
||||||
|
|
||||||
|
export function ArticleMeta({
|
||||||
|
article,
|
||||||
|
}: ArticleMetaProps ) {
|
||||||
|
|
||||||
|
const authors = article.authors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<AvatarGroup max={3}>
|
||||||
|
{authors.map((author, index) => (
|
||||||
|
<Avatar
|
||||||
|
key={index}
|
||||||
|
alt={author.name}
|
||||||
|
src={author.avatar}
|
||||||
|
sx={{ width: 24, height: 24 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{authors.map((author) => author.name).join(', ')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(article.created_at).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ 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 Fade from '@mui/material/Fade'; // ✅ for smooth appearance
|
import Fade from '@mui/material/Fade';
|
||||||
|
|
||||||
|
|
||||||
const StyledTypography = styled(Typography)({
|
const StyledTypography = styled(Typography)({
|
||||||
|
|||||||
@@ -1,91 +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 { ArticleModel } from "../types/models";
|
||||||
const StyledCard = styled(Card)(({ theme }) => ({
|
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
|
||||||
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 (
|
||||||
@@ -112,32 +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>
|
|
||||||
</div>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'flex', sm: 'none' },
|
display: { xs: 'flex', sm: 'none' },
|
||||||
@@ -171,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}
|
||||||
size="medium"
|
onClick={() => handleTagClick(tag)}
|
||||||
label="Company"
|
size="medium"
|
||||||
sx={{
|
label={tag === 'all' ? 'All categories' : tag.charAt(0).toUpperCase() + tag.slice(1)}
|
||||||
backgroundColor: 'transparent',
|
color={activeTag === tag ? 'primary' : 'default'}
|
||||||
border: 'none',
|
variant={activeTag === tag ? 'filled' : 'outlined'}
|
||||||
}}
|
sx={{
|
||||||
/>
|
borderRadius: '8px',
|
||||||
<Chip
|
fontWeight: activeTag === tag ? 600 : 400,
|
||||||
onClick={handleClick}
|
textTransform: 'capitalize',
|
||||||
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={{
|
||||||
@@ -224,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface ProfileProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Profile({ onBack }: ProfileProps) {
|
export default function Profile({ onBack }: ProfileProps) {
|
||||||
const { currentUser, loading, error, token, refreshAuthors, updateProfile } = useAuth();
|
const { currentUser, loading, error, logout, updateProfile } = useAuth();
|
||||||
const [formData, setFormData] = React.useState({
|
const [formData, setFormData] = React.useState({
|
||||||
username: currentUser?.username || '',
|
username: currentUser?.username || '',
|
||||||
name: currentUser?.name || '',
|
name: currentUser?.name || '',
|
||||||
@@ -56,6 +56,10 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -161,6 +165,15 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
>
|
>
|
||||||
{saving ? <CircularProgress size={24} color="inherit" /> : 'Save Changes'}
|
{saving ? <CircularProgress size={24} color="inherit" /> : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
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 } = useAuth(); // ✅ access token if needed
|
const { token } = useAuth();
|
||||||
|
|
||||||
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
@@ -18,7 +18,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 10 } });
|
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(formatted);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.post('/auth/register', { username, password });
|
const res = await api.post('/auth/register', { username, password });
|
||||||
return res.data; // returns PublicUser from backend
|
return res.data; // returns PublicUser from the backend
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Registration failed:', err);
|
console.error('Registration failed:', err);
|
||||||
setError(err.response?.data?.detail || 'Registration failed');
|
setError(err.response?.data?.detail || 'Registration failed');
|
||||||
@@ -109,7 +109,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
setCurrentUser(fullUser);
|
setCurrentUser(fullUser);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch current user:', err);
|
console.error('Failed to fetch current user:', err);
|
||||||
logout(); // invalid/expired token
|
logout();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ArticleModel } from "./models";
|
import { ArticleModel } from "./models";
|
||||||
|
import {styled} from "@mui/material/styles";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
|
||||||
export interface LatestProps {
|
export interface LatestProps {
|
||||||
articles: ArticleModel[];
|
articles: ArticleModel[];
|
||||||
@@ -10,3 +12,25 @@ export interface ArticleProps {
|
|||||||
article: ArticleModel;
|
article: ArticleModel;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArticleMetaProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleCardProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
index: number;
|
||||||
|
focusedCardIndex: number | null;
|
||||||
|
onSelectArticle: (index: number) => void;
|
||||||
|
onFocus: (index: number) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleGridProps {
|
||||||
|
articles: ArticleModel[];
|
||||||
|
onSelectArticle: (index: number) => void;
|
||||||
|
xs?: number; // default 12 for mobile full-width
|
||||||
|
md6?: number; // default 6 (half-width)
|
||||||
|
md4?: number; // default 4 (third-width)
|
||||||
|
nested?: 1 | 2; // number of stacked cards in a nested column
|
||||||
|
}
|
||||||
|
|||||||
40
src/blog/types/styles.ts
Normal file
40
src/blog/types/styles.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {styled} from "@mui/material/styles";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import {Typography} from "@mui/material";
|
||||||
|
|
||||||
|
export const StyledCard = styled(Card)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: 0,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '3px solid',
|
||||||
|
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledCardContent = styled(CardContent)({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
padding: 16,
|
||||||
|
flexGrow: 1,
|
||||||
|
'&:last-child': {
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StyledTypography = styled(Typography)({
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user