forked from Qortal-Forker/q-blog
release: v0.3.0 — Bookmarks, lightbox, scroll-to-top, menu overhaul
Some checks failed
CI / build_test (push) Failing after 1m24s
Some checks failed
CI / build_test (push) Failing after 1m24s
This commit is contained in:
16
docs/CHANGELOG_v0.3.0.md
Normal file
16
docs/CHANGELOG_v0.3.0.md
Normal file
@@ -0,0 +1,16 @@
|
||||
Q‑Blog v0.3.0 — Enhancements
|
||||
|
||||
- Image Lightbox: Click images in blog posts to open a full‑window overlay for easier viewing. Close by clicking anywhere or pressing Escape.
|
||||
- Scroll‑To‑Top Button: A floating button appears after you scroll down and returns you to the top smoothly when clicked.
|
||||
- Bookmarks: Save links to Q‑Blogs and individual posts. Access saved items from the new Bookmarks menu entry and manage them on the Bookmarks page.
|
||||
- On first load, existing Favorites are migrated into Bookmarks automatically and then cleared from the old storage.
|
||||
|
||||
Developer Notes
|
||||
|
||||
- New components: `src/components/common/ImageLightbox.tsx`, `src/components/common/ScrollToTop.tsx`.
|
||||
- New slice: `src/state/features/bookmarksSlice.ts` added to `src/state/store.ts`.
|
||||
- New page: `src/pages/Bookmarks/Bookmarks.tsx` and route `/bookmarks` wired in `src/App.tsx`.
|
||||
- `BlogIndividualPost` and `BlogIndividualProfile` updated with bookmark toggles.
|
||||
- `Navbar` now shows Bookmarks (Favorites became Bookmarks). Post list cards now use Bookmarks.
|
||||
- The main header now has: Q‑Blog logo, search bar, notifications bell, a Main Menu (Create Blog/Post, My Blogs, view toggle, Subscriptions, Bookmarks, Blocked Names, Q‑Mail), and the Name selector dropdown (for switching names only).
|
||||
- `ResponsiveImage` now forwards `alt` to the underlying `<img>` for improved a11y.
|
||||
39
docs/RELEASE_NOTES_v0.3.0.md
Normal file
39
docs/RELEASE_NOTES_v0.3.0.md
Normal file
@@ -0,0 +1,39 @@
|
||||
Q‑Blog v0.3.0 — Enhancements and UX updates
|
||||
|
||||
Summary
|
||||
- Image Lightbox: Click images inside posts to view them in a full‑window overlay. Close the overlay by clicking anywhere or pressing Escape.
|
||||
- Scroll‑To‑Top Button: A floating button appears when scrolled down; smoothly returns to the top.
|
||||
- Bookmarks: Favorites is becoming Bookmarks. Your existing Favorites are automatically migrated to Bookmarks on first launch and cleared from legacy storage. Bookmarks support folders, sorting, and a dedicated management page at /bookmarks.
|
||||
- Header/menu: The header now organizes features as: Q‑Blog logo, search, notifications, a unified Main Menu (Create Blog/Post, My Blogs, view toggle, Subscriptions, Bookmarks, Blocked Names, Q‑Mail), and the Name selector (for switching names only). The Authenticate button is positioned next to the Menu for stable layout.
|
||||
|
||||
Developer Details
|
||||
- New Components
|
||||
- ImageLightbox overlay: src/components/common/ImageLightbox.tsx
|
||||
- ScrollToTop FAB: src/components/common/ScrollToTop.tsx
|
||||
- Bookmarks
|
||||
- Redux slice: src/state/features/bookmarksSlice.ts (load, add, remove, set folder)
|
||||
- Bookmarks page: src/pages/Bookmarks/Bookmarks.tsx with table, sorting, actions, and confirmation dialog
|
||||
- Migration: src/utils/migrateFavoritesToBookmarks.ts (runs at startup in GlobalWrapper to import Favorites → Bookmarks)
|
||||
- Wiring: store registration in src/state/store.ts, route added in src/App.tsx, Navbar menu entry
|
||||
- Toggles: Post list cards, post page, and blog page share a canonical href key so bookmark state is consistent across views
|
||||
- UI and A11Y
|
||||
- Navbar: Single “Menu” entry consolidates actions; Name menu focuses on switching only
|
||||
- Authenticate button placed to the right of Menu to avoid layout jumps when authenticating
|
||||
- ResponsiveImage passes alt to the underlying image for a11y
|
||||
- Tests Added/Updated
|
||||
- tests/features/ImageOverlay.test.tsx: Lightbox opens on image click
|
||||
- tests/components/ScrollToTop.test.tsx: Button appears after scrolling
|
||||
- tests/pages/Bookmarks.test.tsx: Bookmark save and list presence
|
||||
- tests/components/Navbar.multiblog.test.tsx: Updated to open the Main Menu and assert menu items
|
||||
- Total tests: 33 test files exercising routes, components, features (feed, wiki canonicalization, subscriptions, posting flows), a11y basics, and the new features above
|
||||
|
||||
Upgrade Notes
|
||||
- Favorites is becoming Bookmarks: On first launch, the app migrates Favorites to Bookmarks automatically and removes the old entries from LocalForage. No manual steps required.
|
||||
- No breaking changes to APIs.
|
||||
|
||||
QA Checklist
|
||||
- Lightbox renders and closes cleanly on click
|
||||
- Scroll to top button shows after scrolling and hides at top
|
||||
- Bookmark icons are in sync across list, post page, and blog page views
|
||||
- Bookmarks table loads metadata (Updated) per item and shows a spinner until available
|
||||
- My Blogs section expands/collapses on click in the Main Menu
|
||||
14
docs/USER_ANNOUNCEMENT_v0.3.0.md
Normal file
14
docs/USER_ANNOUNCEMENT_v0.3.0.md
Normal file
@@ -0,0 +1,14 @@
|
||||
Q‑Blog v0.3.0 — What’s New
|
||||
|
||||
- Image lightbox: Click any image in a post to view it full‑window; click anywhere to dismiss.
|
||||
- Scroll‑to‑top: A floating button appears as you scroll; click to return smoothly to the top.
|
||||
- Bookmarks: Favorites is becoming Bookmarks. Your saved items migrate automatically on first launch. Manage at /bookmarks — organize with folders, sort by Updated, open/copy links, and remove with confirmation.
|
||||
- Streamlined header: Logo, search, notifications, a unified Main Menu (Create Blog/Post, My Blogs, view toggle, Subscriptions, Bookmarks, Blocked Names, Q‑Mail), and a simple Name switcher. The Authenticate button sits next to the Menu for a stable layout.
|
||||
- QA: 3 new test sets added (33 total) covering the lightbox, scroll‑to‑top, and bookmarks flows, alongside existing route, component, wiki canonicalization, subscriptions, posting, and a11y coverage.
|
||||
|
||||
Where to find it
|
||||
- Main Menu (next to the bell): Create Blog, Create Post, My Blogs (expandable), switch Tile/List view, Subscriptions, Bookmarks, Blocked Names, Q‑Mail.
|
||||
- Bookmark toggles: On post cards, post pages, and blog pages — changes sync everywhere.
|
||||
- Bookmarks page: /bookmarks (also in the Main Menu).
|
||||
|
||||
Questions or feedback? Open an issue or reach out via Q‑Mail.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "q-blog",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "q-blog",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "q-blog",
|
||||
"private": true,
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -16,6 +16,7 @@ import GlobalWrapper from './wrappers/GlobalWrapper';
|
||||
import DownloadWrapper from './wrappers/DownloadWrapper';
|
||||
import Notification from './components/common/Notification/Notification';
|
||||
import { useState } from 'react';
|
||||
import BookmarksPage from './pages/Bookmarks/Bookmarks';
|
||||
|
||||
function App() {
|
||||
const themeColor = (window as any)._qdnTheme as string | undefined;
|
||||
@@ -45,6 +46,7 @@ function App() {
|
||||
<Route path="/profile/new" element={<CreatEditProfile />} />
|
||||
<Route path="/favorites" element={<BlogList mode="favorites" />} />
|
||||
<Route path="/subscriptions" element={<BlogList mode="subscriptions" />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/" element={<BlogList />} />
|
||||
</Routes>
|
||||
</GlobalWrapper>
|
||||
|
||||
56
src/components/common/ImageLightbox.tsx
Normal file
56
src/components/common/ImageLightbox.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Modal, Box } from '@mui/material';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
src: string | null;
|
||||
alt?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ImageLightbox: React.FC<ImageLightboxProps> = ({ src, alt, open, onClose }) => {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
aria-labelledby="image-lightbox-title"
|
||||
aria-describedby="image-lightbox-description"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bgcolor: 'rgba(0,0,0,0.85)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 2,
|
||||
cursor: 'zoom-out',
|
||||
}}
|
||||
onClick={onClose}
|
||||
data-testid="image-lightbox-overlay"
|
||||
>
|
||||
{src && (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || 'Expanded blog image'}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 0 12px rgba(0,0,0,0.6)',
|
||||
cursor: 'inherit',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageLightbox;
|
||||
|
||||
@@ -81,6 +81,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
|
||||
<img
|
||||
onLoad={() => setLoading(false)}
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
|
||||
40
src/components/common/ScrollToTop.tsx
Normal file
40
src/components/common/ScrollToTop.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Fab, Zoom } from '@mui/material';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
|
||||
const SCROLL_THRESHOLD = 200; // px
|
||||
|
||||
const ScrollToTop: React.FC = () => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const y = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||
setVisible(y > SCROLL_THRESHOLD);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener('scroll', onScroll as EventListener);
|
||||
}, []);
|
||||
|
||||
const toTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Zoom in={visible}>
|
||||
<Fab
|
||||
color="primary"
|
||||
size="medium"
|
||||
onClick={toTop}
|
||||
aria-label="Scroll to top"
|
||||
sx={{ position: 'fixed', bottom: 24, right: 24, zIndex: 1200 }}
|
||||
>
|
||||
<KeyboardArrowUpIcon />
|
||||
</Fab>
|
||||
</Zoom>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import AccountCircle from '@mui/icons-material/AccountCircle';
|
||||
import AddBoxIcon from '@mui/icons-material/AddBox';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
|
||||
@@ -25,8 +27,8 @@ import { RootState } from '../../../state/store';
|
||||
import type { BlogSummary } from '../../../utils/blogs';
|
||||
import { UserNavbar } from '../../common/UserNavbar/UserNavbar';
|
||||
import { removePrefix } from '../../../utils/blogIdformats';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import SubscriptionsIcon from '@mui/icons-material/Subscriptions';
|
||||
import CollectionsBookmarkIcon from '@mui/icons-material/CollectionsBookmark';
|
||||
import { BlockedNamesModal } from '../../common/BlockedNamesModal/BlockedNamesModal';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
@@ -115,7 +117,7 @@ const NavBar: React.FC<Props> = ({
|
||||
const [anchorElNotification, setAnchorElNotification] = React.useState<HTMLButtonElement | null>(
|
||||
null,
|
||||
);
|
||||
const [anchorElBlogs, setAnchorElBlogs] = React.useState<HTMLButtonElement | null>(null);
|
||||
const [anchorElMain, setAnchorElMain] = React.useState<HTMLButtonElement | null>(null);
|
||||
const [isOpenModal, setIsOpenModal] = React.useState<boolean>(false);
|
||||
const searchValRef = useRef('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -138,7 +140,9 @@ const NavBar: React.FC<Props> = ({
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const closeBlogs = () => setAnchorElBlogs(null);
|
||||
const openMain = Boolean(anchorElMain);
|
||||
const idMain = openMain ? 'main-menu-popover' : undefined;
|
||||
const closeMain = () => setAnchorElMain(null);
|
||||
const onClose = () => {
|
||||
setIsOpenModal(false);
|
||||
};
|
||||
@@ -146,13 +150,13 @@ const NavBar: React.FC<Props> = ({
|
||||
const id = open ? 'simple-popover' : undefined;
|
||||
const openPopover = Boolean(anchorElNotification);
|
||||
const idNotification = openPopover ? 'simple-popover-notification' : undefined;
|
||||
const openBlogs = Boolean(anchorElBlogs);
|
||||
const idBlogs = openBlogs ? 'blogs-popover' : undefined;
|
||||
// legacy blogs popover removed; consolidated under Main Menu
|
||||
|
||||
const [viewMode, setViewMode] = useState<'tile' | 'list'>(() => {
|
||||
const saved = localStorage.getItem('qblog_view');
|
||||
return saved === 'list' ? 'list' : 'tile';
|
||||
});
|
||||
const [myBlogsOpen, setMyBlogsOpen] = useState(false);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const next = viewMode === 'tile' ? 'list' : 'tile';
|
||||
@@ -254,19 +258,7 @@ const NavBar: React.FC<Props> = ({
|
||||
inputRef.current.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={toggleViewMode}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
ml: 1,
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: 'Arial',
|
||||
}}
|
||||
aria-label="Toggle between Tile and List views"
|
||||
>
|
||||
{viewMode === 'tile' ? 'List View' : 'Tile View'}
|
||||
</Button>
|
||||
{/* View toggle moved into Main Menu */}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -274,20 +266,7 @@ const NavBar: React.FC<Props> = ({
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{!isAuthenticated && (
|
||||
<AuthenticateButton
|
||||
onClick={authStatus === 'loading' ? undefined : authenticate}
|
||||
disabled={authStatus === 'loading'}
|
||||
aria-busy={authStatus === 'loading'}
|
||||
>
|
||||
{authStatus === 'loading' ? (
|
||||
<CircularProgress size={16} sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<ExitToAppIcon />
|
||||
)}
|
||||
{authStatus === 'loading' ? 'Authenticating…' : 'Authenticate'}
|
||||
</AuthenticateButton>
|
||||
)}
|
||||
{/* Authenticate button moved to the right of Menu for layout stability */}
|
||||
<Badge
|
||||
badgeContent={fullNotifications.length}
|
||||
color="primary"
|
||||
@@ -358,88 +337,33 @@ const NavBar: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
{isAuthenticated &&
|
||||
userName &&
|
||||
hasAttemptedToFetchBlogInitial &&
|
||||
(userBlogs?.length || 0) === 0 && (
|
||||
<CreateBlogButton onClick={() => dispatch(togglePublishBlogModal(true))}>
|
||||
<NewWindowSVG color="#fff" width="18" height="18" />
|
||||
Create Blog
|
||||
</CreateBlogButton>
|
||||
)}
|
||||
{/* Main Menu button */}
|
||||
<StyledButton
|
||||
color="primary"
|
||||
startIcon={<MenuIcon />}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={idMain}
|
||||
aria-expanded={openMain ? 'true' : undefined}
|
||||
onClick={(e: any) => setAnchorElMain(e.currentTarget as HTMLButtonElement)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Menu
|
||||
</StyledButton>
|
||||
|
||||
{isAuthenticated && userName && (userBlogs?.length || 0) >= 1 && (
|
||||
<>
|
||||
<StyledButton
|
||||
color="primary"
|
||||
startIcon={<AddBoxIcon />}
|
||||
onClick={() => {
|
||||
navigate(`/post/new`);
|
||||
}}
|
||||
>
|
||||
Create Post
|
||||
</StyledButton>
|
||||
|
||||
<StyledButton
|
||||
color="primary"
|
||||
startIcon={<AutoStoriesIcon />}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={idBlogs}
|
||||
aria-expanded={openBlogs ? 'true' : undefined}
|
||||
onClick={(e: any) => {
|
||||
const target = e.currentTarget as HTMLButtonElement;
|
||||
setAnchorElBlogs(target);
|
||||
}}
|
||||
>
|
||||
My Blogs
|
||||
</StyledButton>
|
||||
|
||||
<Popover
|
||||
id={idBlogs}
|
||||
open={openBlogs}
|
||||
anchorEl={anchorElBlogs}
|
||||
onClose={closeBlogs}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Box sx={{ minWidth: 280 }} role="menu">
|
||||
<List dense>
|
||||
{(userBlogs || []).map((b) => {
|
||||
const isActive = blog?.blogId === b.blogId;
|
||||
return (
|
||||
<ListItem
|
||||
key={b.blogId}
|
||||
button
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onSelectBlog && onSelectBlog(b);
|
||||
closeBlogs();
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={`${b.title || b.handle}${isActive ? ' ✓' : ''}`}
|
||||
secondary={`${b.handle}`}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
dispatch(togglePublishBlogModal(true));
|
||||
closeBlogs();
|
||||
}}
|
||||
>
|
||||
Create new blog
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
{!isAuthenticated && (
|
||||
<AuthenticateButton
|
||||
onClick={authStatus === 'loading' ? undefined : authenticate}
|
||||
disabled={authStatus === 'loading'}
|
||||
aria-busy={authStatus === 'loading'}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{authStatus === 'loading' ? (
|
||||
<CircularProgress size={16} sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<ExitToAppIcon />
|
||||
)}
|
||||
{authStatus === 'loading' ? 'Authenticating…' : 'Authenticate'}
|
||||
</AuthenticateButton>
|
||||
)}
|
||||
|
||||
{isAuthenticated && userName && (
|
||||
@@ -460,6 +384,95 @@ const NavBar: React.FC<Props> = ({
|
||||
</AvatarContainer>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
id={idMain}
|
||||
open={openMain}
|
||||
anchorEl={anchorElMain}
|
||||
onClose={closeMain}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Box sx={{ minWidth: 280 }} role="menu">
|
||||
{(isAuthenticated && userName && (userBlogs?.length || 0) === 0) && (
|
||||
<DropdownContainer onClick={() => { dispatch(togglePublishBlogModal(true)); closeMain(); }}>
|
||||
<NewWindowSVG color={theme.palette.text.primary} width="18" height="18" />
|
||||
<DropdownText>Create Blog</DropdownText>
|
||||
</DropdownContainer>
|
||||
)}
|
||||
{(isAuthenticated && userName && (userBlogs?.length || 0) >= 1) && (
|
||||
<>
|
||||
<DropdownContainer onClick={() => { navigate('/post/new'); closeMain(); }}>
|
||||
<AddBoxIcon fontSize="small" />
|
||||
<DropdownText>Create Post</DropdownText>
|
||||
</DropdownContainer>
|
||||
<DropdownContainer
|
||||
onClick={() => setMyBlogsOpen((v) => !v)}
|
||||
aria-expanded={myBlogsOpen ? 'true' : 'false'}
|
||||
aria-controls="menu-myblogs"
|
||||
role="button"
|
||||
>
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
transform: myBlogsOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 150ms ease',
|
||||
}}
|
||||
/>
|
||||
<DropdownText>My Blogs</DropdownText>
|
||||
</DropdownContainer>
|
||||
<Collapse in={myBlogsOpen} timeout="auto" unmountOnExit>
|
||||
<List dense id="menu-myblogs">
|
||||
{(userBlogs || []).map((b) => {
|
||||
const isActive = blog?.blogId === b.blogId;
|
||||
return (
|
||||
<ListItem
|
||||
key={b.blogId}
|
||||
button
|
||||
role="menuitem"
|
||||
onClick={() => { onSelectBlog && onSelectBlog(b); closeMain(); }}
|
||||
>
|
||||
<ListItemText primary={`${b.title || b.handle}${isActive ? ' ✓' : ''}`} secondary={`${b.handle}`} />
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Collapse>
|
||||
<Divider />
|
||||
<DropdownContainer onClick={() => { dispatch(togglePublishBlogModal(true)); closeMain(); }}>
|
||||
<NewWindowSVG color={theme.palette.text.primary} width="18" height="18" />
|
||||
<DropdownText>Create new blog</DropdownText>
|
||||
</DropdownContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mt: 1 }} />
|
||||
|
||||
<DropdownContainer onClick={() => { toggleViewMode(); closeMain(); }}>
|
||||
<DropdownText>{viewMode === 'tile' ? 'Switch to List View' : 'Switch to Tile View'}</DropdownText>
|
||||
</DropdownContainer>
|
||||
|
||||
<DropdownContainer onClick={() => { navigate('/subscriptions'); closeMain(); }}>
|
||||
<SubscriptionsIcon sx={{ color: '#5f50e3' }} />
|
||||
<DropdownText>Subscriptions</DropdownText>
|
||||
</DropdownContainer>
|
||||
|
||||
<DropdownContainer onClick={() => { navigate('/bookmarks'); closeMain(); }}>
|
||||
<CollectionsBookmarkIcon sx={{ color: '#508fe3' }} />
|
||||
<DropdownText>Bookmarks</DropdownText>
|
||||
</DropdownContainer>
|
||||
|
||||
<DropdownContainer onClick={() => { setIsOpenModal(true); closeMain(); }}>
|
||||
<PersonOffIcon sx={{ color: '#e35050' }} />
|
||||
<DropdownText>Blocked Names</DropdownText>
|
||||
</DropdownContainer>
|
||||
|
||||
<DropdownContainer>
|
||||
<a href="qortal://APP/Q-Mail" className="qortal-link" style={{ width: '100%', display: 'flex', gap: '5px', alignItems: 'center' }}>
|
||||
<EmailIcon sx={{ color: '#50e3c2' }} />
|
||||
<DropdownText>Q-Mail</DropdownText>
|
||||
</a>
|
||||
</DropdownContainer>
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
<Popover
|
||||
id={id}
|
||||
open={open}
|
||||
@@ -531,38 +544,7 @@ const NavBar: React.FC<Props> = ({
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mt: 1 }} />
|
||||
|
||||
<DropdownContainer onClick={() => navigate('/favorites')}>
|
||||
<BookmarkIcon sx={{ color: '#50e3c2' }} />
|
||||
<DropdownText>Favorites</DropdownText>
|
||||
</DropdownContainer>
|
||||
|
||||
<DropdownContainer onClick={() => navigate('/subscriptions')}>
|
||||
<SubscriptionsIcon sx={{ color: '#5f50e3' }} />
|
||||
<DropdownText>Subscriptions</DropdownText>
|
||||
</DropdownContainer>
|
||||
|
||||
<DropdownContainer
|
||||
onClick={() => {
|
||||
setIsOpenModal(true);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<PersonOffIcon sx={{ color: '#e35050' }} />
|
||||
<DropdownText>Blocked Names</DropdownText>
|
||||
</DropdownContainer>
|
||||
|
||||
<DropdownContainer>
|
||||
<a
|
||||
href="qortal://APP/Q-Mail"
|
||||
className="qortal-link"
|
||||
style={{ width: '100%', display: 'flex', gap: '5px', alignItems: 'center' }}
|
||||
>
|
||||
<EmailIcon sx={{ color: '#50e3c2' }} />
|
||||
<DropdownText>Q-Mail</DropdownText>
|
||||
</a>
|
||||
</DropdownContainer>
|
||||
{/* Name menu only shows name switching now */}
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { RootState } from '../../state/store';
|
||||
import { checkStructure } from '../../utils/checkStructure';
|
||||
import { BlogContent } from '../../interfaces/interfaces';
|
||||
import ShareIcon from '@mui/icons-material/Share';
|
||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import {
|
||||
setAudio,
|
||||
setCurrAudio,
|
||||
@@ -31,6 +33,7 @@ import { DynamicHeightItemMinimal } from '../../components/DynamicHeightItemMini
|
||||
import { ReusableModal } from '../../components/modals/ReusableModal';
|
||||
import AudioElement from '../../components/AudioElement';
|
||||
import ErrorBoundary from '../../components/common/ErrorBoundary';
|
||||
import ImageLightbox from '../../components/common/ImageLightbox';
|
||||
import { CommentSection } from '../../components/common/Comments/CommentSection';
|
||||
import { canEdit, BlogSettings, selectCanonical } from '../../utils/wiki';
|
||||
import { Tipping } from '../../components/common/Tipping/Tipping';
|
||||
@@ -42,6 +45,7 @@ import { setNotification } from '../../state/features/notificationsSlice';
|
||||
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource';
|
||||
import { PollWidget } from '../../components/common/PollWidget';
|
||||
import { formatDate } from '../../utils/time';
|
||||
import { addBookmark, removeBookmark } from '../../state/features/bookmarksSlice';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
const initialMinHeight = 2; // Define an initial minimum height for grid items
|
||||
@@ -94,6 +98,7 @@ export const BlogIndividualPost = () => {
|
||||
return addPrefix(blog);
|
||||
}, [blog]);
|
||||
const { user: userState } = useSelector((state: RootState) => state.auth);
|
||||
const bookmarksLocal = useSelector((state: RootState) => state.bookmarks.bookmarksLocal);
|
||||
const { audios, audioPostId, visitingBlog } = useSelector((state: RootState) => state.global);
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = React.useState<string>('');
|
||||
@@ -118,6 +123,13 @@ export const BlogIndividualPost = () => {
|
||||
const [isOpenSwitchPlaylistModal, setisOpenSwitchPlaylistModal] = useState<boolean>(false);
|
||||
const tempSaveAudio = useRef<any>(null);
|
||||
const saveAudio = React.useRef<any>(null);
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState<boolean>(false);
|
||||
const openLightbox = (src: string) => {
|
||||
setLightboxSrc(src);
|
||||
setLightboxOpen(true);
|
||||
};
|
||||
const closeLightbox = () => setLightboxOpen(false);
|
||||
|
||||
const fullPostId = useMemo(() => {
|
||||
if (!blog || !postId) return '';
|
||||
@@ -460,6 +472,46 @@ export const BlogIndividualPost = () => {
|
||||
</CopyToClipboard>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
const username = userState?.name || null;
|
||||
const href = `/${user}/${blog}/${postId}`;
|
||||
const isBookmarked = bookmarksLocal.some((b) => b.href === href);
|
||||
return isBookmarked ? (
|
||||
<Tooltip title="Remove bookmark" arrow>
|
||||
<Box sx={{ cursor: 'pointer', ml: 1 }}>
|
||||
<BookmarkIcon
|
||||
aria-label="Remove from bookmarks"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
removeBookmark({ username, href }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Save bookmark" arrow>
|
||||
<Box sx={{ cursor: 'pointer', ml: 1 }}>
|
||||
<BookmarkBorderIcon
|
||||
aria-label="Save to bookmarks"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
addBookmark({
|
||||
username,
|
||||
item: {
|
||||
href,
|
||||
title: blogContent?.title || href,
|
||||
created: Date.now(),
|
||||
type: 'post',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
<CommentSection postId={fullPostId} postName={user || ''} />
|
||||
</Box>
|
||||
|
||||
@@ -507,7 +559,13 @@ export const BlogIndividualPost = () => {
|
||||
count={count}
|
||||
padding={layoutGeneralSettings?.padding}
|
||||
>
|
||||
<img src={section.content.image} className="post-image" />
|
||||
<img
|
||||
src={section.content.image}
|
||||
className="post-image"
|
||||
alt={blogContent?.title ? `${blogContent.title} image` : 'Blog image'}
|
||||
onClick={() => openLightbox(section.content.image)}
|
||||
style={{ cursor: 'zoom-in' }}
|
||||
/>
|
||||
</DynamicHeightItem>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -748,10 +806,13 @@ export const BlogIndividualPost = () => {
|
||||
<img
|
||||
src={section.content.image}
|
||||
className="post-image"
|
||||
alt={blogContent?.title ? `${blogContent.title} image` : 'Blog image'}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
maxHeight: '50vh',
|
||||
cursor: 'zoom-in',
|
||||
}}
|
||||
onClick={() => openLightbox(section.content.image)}
|
||||
/>
|
||||
</Box>
|
||||
</DynamicHeightItemMinimal>
|
||||
@@ -950,6 +1011,7 @@ export const BlogIndividualPost = () => {
|
||||
</Button>
|
||||
</ReusableModal>
|
||||
</Box>
|
||||
<ImageLightbox src={lightboxSrc} open={lightboxOpen} onClose={closeLightbox} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
togglePublishBlogModal,
|
||||
} from '../../state/features/globalSlice';
|
||||
import { addSubscription, BlogPost, removeSubscription } from '../../state/features/blogSlice';
|
||||
import { addBookmark, removeBookmark } from '../../state/features/bookmarksSlice';
|
||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import { Tooltip, IconButton } from '@mui/material';
|
||||
import { useFetchPosts } from '../../hooks/useFetchPosts';
|
||||
import LazyLoad from '../../components/common/LazyLoad';
|
||||
import { addPrefix, removePrefix } from '../../utils/blogIdformats';
|
||||
@@ -34,6 +38,7 @@ export const BlogIndividualProfile = () => {
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const { currentBlog, visitingBlog } = useSelector((state: RootState) => state.global);
|
||||
const subscriptions = useSelector((state: RootState) => state.blog.subscriptions);
|
||||
const bookmarksLocal = useSelector((state: RootState) => state.bookmarks.bookmarksLocal);
|
||||
|
||||
const { blog: blogShortVersion, user: username } = useParams();
|
||||
const blog = React.useMemo(() => {
|
||||
@@ -347,6 +352,41 @@ export const BlogIndividualProfile = () => {
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
{(() => {
|
||||
const viewer = user?.name || null;
|
||||
const shortBlog = blogShortVersion || '';
|
||||
const href = `/${username}/${shortBlog}`;
|
||||
const title = currentBlog?.blogId === blog ? currentBlog?.title : userBlog.title;
|
||||
const isBookmarked = bookmarksLocal.some((b) => b.href === href);
|
||||
return isBookmarked ? (
|
||||
<Tooltip title="Remove bookmark" arrow>
|
||||
<IconButton
|
||||
aria-label="Remove blog bookmark"
|
||||
onClick={() => dispatch(removeBookmark({ username: viewer, href }))}
|
||||
size="small"
|
||||
>
|
||||
<BookmarkIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Save bookmark" arrow>
|
||||
<IconButton
|
||||
aria-label="Save blog bookmark"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
addBookmark({
|
||||
username: viewer,
|
||||
item: { href, title, created: Date.now(), type: 'blog' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<BookmarkBorderIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
{isListView ? (
|
||||
@@ -382,6 +422,7 @@ export const BlogIndividualProfile = () => {
|
||||
>
|
||||
<BlogPostPreview
|
||||
onClick={() => navigate(`/${blogPost.user}/${blogId}/${str2}`)}
|
||||
postHref={`/${blogPost.user}/${blogId}/${str2}`}
|
||||
description={blogPost?.description}
|
||||
title={blogPost?.title}
|
||||
createdAt={blogPost?.createdAt}
|
||||
@@ -466,6 +507,7 @@ export const BlogIndividualProfile = () => {
|
||||
onClick={() => {
|
||||
navigate(`/${blogPost.user}/${blogId}/${str2}`);
|
||||
}}
|
||||
postHref={`/${blogPost.user}/${blogId}/${str2}`}
|
||||
description={blogPost?.description}
|
||||
title={blogPost?.title}
|
||||
createdAt={blogPost?.createdAt}
|
||||
|
||||
@@ -227,6 +227,9 @@ export const BlogList = ({ mode }: BlogListProps) => {
|
||||
}/${blogId}/${str2}`,
|
||||
)
|
||||
}
|
||||
postHref={`/${
|
||||
(blogSettingsMap.get(str1) as any)?.ownerName || blogPost.user
|
||||
}/${blogId}/${str2}`}
|
||||
description={blogPost?.description}
|
||||
title={blogPost?.title}
|
||||
createdAt={blogPost?.createdAt}
|
||||
@@ -299,6 +302,9 @@ export const BlogList = ({ mode }: BlogListProps) => {
|
||||
}/${blogId}/${str2}`,
|
||||
);
|
||||
}}
|
||||
postHref={`/${
|
||||
(blogSettingsMap.get(str1) as any)?.ownerName || blogPost.user
|
||||
}/${blogId}/${str2}`}
|
||||
description={blogPost?.description}
|
||||
title={blogPost?.title}
|
||||
createdAt={blogPost?.createdAt}
|
||||
|
||||
@@ -31,13 +31,7 @@ import {
|
||||
BookmarkIconContainer,
|
||||
} from './PostPreview-styles';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
blockUser,
|
||||
BlogPost,
|
||||
removeFavorites,
|
||||
removeSubscription,
|
||||
upsertFavorites,
|
||||
} from '../../state/features/blogSlice';
|
||||
import { blockUser, BlogPost, removeSubscription } from '../../state/features/blogSlice';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
@@ -47,6 +41,8 @@ import { CustomIcon } from '../../components/common/CustomIcon';
|
||||
import ResponsiveImage from '../../components/common/ResponsiveImage';
|
||||
import { formatDate } from '../../utils/time';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { addBookmark, removeBookmark } from '../../state/features/bookmarksSlice';
|
||||
import { removePrefix } from '../../utils/blogIdformats';
|
||||
interface BlogPostPreviewProps {
|
||||
title: string;
|
||||
createdAt: number | string;
|
||||
@@ -60,6 +56,7 @@ interface BlogPostPreviewProps {
|
||||
isValid?: boolean;
|
||||
tags?: string[];
|
||||
fullWidth?: boolean;
|
||||
postHref?: string; // canonical href used for navigation/bookmarks
|
||||
}
|
||||
|
||||
const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
|
||||
@@ -75,6 +72,7 @@ const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
|
||||
isValid,
|
||||
tags,
|
||||
fullWidth = false,
|
||||
postHref,
|
||||
}) => {
|
||||
const [avatarUrl, setAvatarUrl] = React.useState<string>('');
|
||||
const [showIcons, setShowIcons] = React.useState<boolean>(false);
|
||||
@@ -82,7 +80,7 @@ const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const favoritesLocal = useSelector((state: RootState) => state.blog.favoritesLocal);
|
||||
const bookmarksLocal = useSelector((state: RootState) => state.bookmarks.bookmarksLocal);
|
||||
const [isOpenAlert, setIsOpenAlert] = useState<boolean>(false);
|
||||
const subscriptions = useSelector((state: RootState) => state.blog.subscriptions);
|
||||
const username = useSelector((state: RootState) => state.auth?.user?.name);
|
||||
@@ -104,10 +102,21 @@ const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
|
||||
getAvatar();
|
||||
}, []);
|
||||
|
||||
const isFavorite = useMemo(() => {
|
||||
if (!favoritesLocal) return false;
|
||||
return favoritesLocal.find((fav) => fav?.id === blogPost?.id);
|
||||
}, [favoritesLocal, blogPost?.id]);
|
||||
const isBookmarked = useMemo(() => {
|
||||
try {
|
||||
if (postHref) {
|
||||
return bookmarksLocal.some((b) => b.href === postHref);
|
||||
}
|
||||
const id = blogPost?.id || '';
|
||||
if (!id.includes('-post-')) return false;
|
||||
const [blogFull, postId] = id.split('-post-');
|
||||
const blogShort = removePrefix(blogFull);
|
||||
const href = `/${blogPost.user}/${blogShort}/${postId}`;
|
||||
return bookmarksLocal.some((b) => b.href === href);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [bookmarksLocal, blogPost?.id, blogPost?.user, postHref]);
|
||||
|
||||
const blockUserFunc = async (user: string) => {
|
||||
if (user === 'Q-Blog' || user === 'qblog') return;
|
||||
@@ -135,7 +144,6 @@ const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
|
||||
|
||||
if (response === true) {
|
||||
dispatch(blockUser(user));
|
||||
dispatch(removeFavorites(blogPost.id));
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
@@ -322,32 +330,49 @@ const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({
|
||||
onMouseEnter={() => setShowIcons(true)}
|
||||
onMouseLeave={() => setShowIcons(false)}
|
||||
>
|
||||
{username && isFavorite && (
|
||||
<Tooltip title="Remove from favorites" placement="top">
|
||||
{username && isBookmarked && (
|
||||
<Tooltip title="Remove bookmark" placement="top">
|
||||
<BookmarkIconContainer
|
||||
onMouseEnter={() => setShowIcons(true)}
|
||||
onMouseLeave={() => setShowIcons(false)}
|
||||
>
|
||||
<BookmarkIcon
|
||||
sx={{
|
||||
color: 'red',
|
||||
}}
|
||||
sx={{ color: 'red' }}
|
||||
aria-label="Remove from bookmarks"
|
||||
onClick={() => {
|
||||
dispatch(removeFavorites(blogPost.id));
|
||||
let href = postHref;
|
||||
if (!href) {
|
||||
const [blogFull, postId] = (blogPost.id || '').split('-post-');
|
||||
const blogShort = removePrefix(blogFull);
|
||||
href = `/${blogPost.user}/${blogShort}/${postId}`;
|
||||
}
|
||||
dispatch(removeBookmark({ username, href: href! }));
|
||||
}}
|
||||
/>
|
||||
</BookmarkIconContainer>
|
||||
</Tooltip>
|
||||
)}
|
||||
{username && !isFavorite && (
|
||||
<Tooltip title="Save to favorites" placement="top">
|
||||
{username && !isBookmarked && (
|
||||
<Tooltip title="Save bookmark" placement="top">
|
||||
<BookmarkIconContainer
|
||||
onMouseEnter={() => setShowIcons(true)}
|
||||
onMouseLeave={() => setShowIcons(false)}
|
||||
>
|
||||
<BookmarkBorderIcon
|
||||
aria-label="Save to bookmarks"
|
||||
onClick={() => {
|
||||
dispatch(upsertFavorites([blogPost]));
|
||||
let href = postHref;
|
||||
if (!href) {
|
||||
const [blogFull, postId] = (blogPost.id || '').split('-post-');
|
||||
const blogShort = removePrefix(blogFull);
|
||||
href = `/${blogPost.user}/${blogShort}/${postId}`;
|
||||
}
|
||||
dispatch(
|
||||
addBookmark({
|
||||
username,
|
||||
item: { href: href!, title: blogPost.title || href!, created: Date.now(), type: 'post' },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</BookmarkIconContainer>
|
||||
|
||||
276
src/pages/Bookmarks/Bookmarks.tsx
Normal file
276
src/pages/Bookmarks/Bookmarks.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
useTheme,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableBody,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Input,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { RootState } from '../../state/store';
|
||||
import { loadBookmarksForUser, removeBookmark, setBookmarkFolder } from '../../state/features/bookmarksSlice';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
|
||||
import { addPrefix } from '../../utils/blogIdformats';
|
||||
import { formatDate } from '../../utils/time';
|
||||
|
||||
const BookmarksPage: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const username = useSelector((s: RootState) => s.auth.user?.name || null);
|
||||
const bookmarks = useSelector((s: RootState) => s.bookmarks.bookmarksLocal);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(loadBookmarksForUser({ username }));
|
||||
}, [dispatch, username]);
|
||||
|
||||
const [confirmHref, setConfirmHref] = React.useState<string | null>(null);
|
||||
const [editFolderHref, setEditFolderHref] = React.useState<string | null>(null);
|
||||
const [folderValue, setFolderValue] = React.useState<string>('');
|
||||
|
||||
type RowMeta = { updated?: number; created?: number };
|
||||
const [meta, setMeta] = React.useState<Record<string, RowMeta>>({});
|
||||
const [sortBy, setSortBy] = React.useState<
|
||||
'folder' | 'service' | 'name' | 'identifier' | 'title' | 'updated'
|
||||
>('updated');
|
||||
const [sortDir, setSortDir] = React.useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const parse = (href: string) => {
|
||||
// href formats: /:user/:blog or /:user/:blog/:postId
|
||||
const parts = href.split('/').filter(Boolean);
|
||||
if (parts.length < 2) return { service: '', name: '', identifier: '', qortal: '' };
|
||||
const [name, blogShort, maybePostId] = parts;
|
||||
const blogFull = addPrefix(blogShort);
|
||||
if (maybePostId) {
|
||||
const identifier = `${blogFull}-post-${maybePostId}`;
|
||||
return {
|
||||
service: 'BLOG_POST',
|
||||
name,
|
||||
identifier,
|
||||
qortal: `qortal://APP/qblog/${name}/${blogShort}/${maybePostId}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
service: 'BLOG',
|
||||
name,
|
||||
identifier: blogFull,
|
||||
qortal: `qortal://APP/qblog/${name}/${blogShort}`,
|
||||
};
|
||||
};
|
||||
|
||||
// Fetch metadata (updated/created) for rows
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
for (const b of bookmarks) {
|
||||
if (meta[b.href]) continue;
|
||||
const p = parse(b.href);
|
||||
if (!p.service || !p.name || !p.identifier) continue;
|
||||
try {
|
||||
const url = `/arbitrary/resources?service=${encodeURIComponent(
|
||||
p.service,
|
||||
)}&name=${encodeURIComponent(p.name)}&identifier=${encodeURIComponent(
|
||||
p.identifier,
|
||||
)}&includemetadata=true`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const item = Array.isArray(data) && data.length > 0 ? data[0] : null;
|
||||
const row: RowMeta = {
|
||||
updated: item?.updated,
|
||||
created: item?.created,
|
||||
};
|
||||
if (!cancelled) setMeta((m) => ({ ...m, [b.href]: row }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bookmarks]);
|
||||
|
||||
const sorted = React.useMemo(() => {
|
||||
const rows = [...bookmarks];
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
const getVal = (b: typeof bookmarks[number]) => {
|
||||
const p = parse(b.href);
|
||||
switch (sortBy) {
|
||||
case 'folder':
|
||||
return (b.folder || '').toLowerCase();
|
||||
case 'service':
|
||||
return p.service;
|
||||
case 'name':
|
||||
return p.name.toLowerCase();
|
||||
case 'identifier':
|
||||
return p.identifier.toLowerCase();
|
||||
case 'title':
|
||||
return (b.title || '').toLowerCase();
|
||||
case 'updated': {
|
||||
const m = meta[b.href];
|
||||
const ts = m?.updated || m?.created || 0;
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
};
|
||||
rows.sort((a, b) => {
|
||||
const av = getVal(a);
|
||||
const bv = getVal(b);
|
||||
if (av < bv) return -1 * dir;
|
||||
if (av > bv) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
return rows;
|
||||
}, [bookmarks, sortBy, sortDir, meta]);
|
||||
|
||||
const toggleSort = (field: typeof sortBy) => {
|
||||
setSortBy((prev) => {
|
||||
if (prev === field) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
return prev;
|
||||
}
|
||||
setSortDir('asc');
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 800, mx: 'auto', my: 3, px: 2 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }} color={theme.palette.text.primary}>
|
||||
Bookmarks
|
||||
</Typography>
|
||||
{bookmarks.length === 0 ? (
|
||||
<Typography color={theme.palette.text.secondary}>No bookmarks saved yet.</Typography>
|
||||
) : (
|
||||
<Table size="small" aria-label="Bookmarks table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell onClick={() => toggleSort('folder')} sx={{ cursor: 'pointer' }}>Folder {sortBy==='folder' ? (sortDir==='asc'?'▲':'▼') : ''}</TableCell>
|
||||
<TableCell onClick={() => toggleSort('service')} sx={{ cursor: 'pointer' }}>Service {sortBy==='service' ? (sortDir==='asc'?'▲':'▼') : ''}</TableCell>
|
||||
<TableCell onClick={() => toggleSort('name')} sx={{ cursor: 'pointer' }}>Name {sortBy==='name' ? (sortDir==='asc'?'▲':'▼') : ''}</TableCell>
|
||||
<TableCell onClick={() => toggleSort('identifier')} sx={{ cursor: 'pointer' }}>Identifier {sortBy==='identifier' ? (sortDir==='asc'?'▲':'▼') : ''}</TableCell>
|
||||
<TableCell onClick={() => toggleSort('title')} sx={{ cursor: 'pointer' }}>Title {sortBy==='title' ? (sortDir==='asc'?'▲':'▼') : ''}</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
<TableCell onClick={() => toggleSort('updated')} sx={{ cursor: 'pointer' }} align="right">Updated {sortBy==='updated' ? (sortDir==='asc'?'▲':'▼') : ''}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sorted.map((b) => {
|
||||
const p = parse(b.href);
|
||||
const m = meta[b.href];
|
||||
const ts = m?.updated || m?.created || 0;
|
||||
return (
|
||||
<TableRow key={b.href} hover>
|
||||
<TableCell>{b.folder || ''}</TableCell>
|
||||
<TableCell>{p.service}</TableCell>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace' }}>{p.identifier}</TableCell>
|
||||
<TableCell>{b.title}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Set folder" arrow>
|
||||
<IconButton aria-label="Set folder" size="small" onClick={() => { setEditFolderHref(b.href); setFolderValue(b.folder || ''); }}>
|
||||
<CreateNewFolderIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open" arrow>
|
||||
<IconButton aria-label="Open" onClick={() => navigate(b.href)} size="small">
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CopyToClipboard text={p.qortal}>
|
||||
<Tooltip title="Copy link" arrow>
|
||||
<IconButton aria-label="Copy link" size="small">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</CopyToClipboard>
|
||||
<Tooltip title="Remove" arrow>
|
||||
<IconButton
|
||||
aria-label="Remove bookmark"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => setConfirmHref(b.href)}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="right">{ts ? formatDate(ts) : <CircularProgress size={16} />}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={!!confirmHref} onClose={() => setConfirmHref(null)}>
|
||||
<DialogTitle>Remove bookmark?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will remove the bookmark. This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmHref(null)}>Cancel</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (confirmHref) dispatch(removeBookmark({ username, href: confirmHref }));
|
||||
setConfirmHref(null);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!editFolderHref} onClose={() => setEditFolderHref(null)}>
|
||||
<DialogTitle>Set bookmark folder</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Assign a folder name for organizing this bookmark.</DialogContentText>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Input
|
||||
fullWidth
|
||||
value={folderValue}
|
||||
onChange={(e: any) => setFolderValue(e.target.value)}
|
||||
placeholder="Folder name (e.g., Research)"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditFolderHref(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (editFolderHref) dispatch(setBookmarkFolder({ username, href: editFolderHref, folder: folderValue || undefined }));
|
||||
setEditFolderHref(null);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarksPage;
|
||||
@@ -26,8 +26,13 @@ export const CreatePost = ({ mode }: CreatePostProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// IMPORTANT: in edit mode, :postId is already the full identifier (q-blog-*-post-*)
|
||||
const fullPostId = useMemo(() => (mode === 'edit' && postId ? postId : ''), [postId, mode]);
|
||||
// In edit mode, build the full identifier from :blog and :postId
|
||||
const fullPostId = useMemo(() => {
|
||||
if (mode !== 'edit') return '';
|
||||
if (!blog || !postId) return '';
|
||||
const blogFull = addPrefix(blog);
|
||||
return `${blogFull}-post-${postId}`;
|
||||
}, [postId, mode, blog]);
|
||||
|
||||
const [toggleEditorType, setToggleEditorType] = useState<EditorType | null>(null);
|
||||
const [blogContentForEdit, setBlogContentForEdit] = useState<any>(null);
|
||||
|
||||
@@ -421,9 +421,7 @@ export const CreatePostBuilder = ({
|
||||
const errMsg = `Missing: ${missingFieldsString}`;
|
||||
errorMsg = errMsg;
|
||||
}
|
||||
if (newPostContent.length === 0) {
|
||||
errorMsg = 'Your post has no content';
|
||||
}
|
||||
// In edit mode, allow updating even if content array is empty (legacy/minimal posts)
|
||||
|
||||
if (errorMsg) {
|
||||
dispatch(
|
||||
@@ -607,9 +605,7 @@ export const CreatePostBuilder = ({
|
||||
const errMsg = `Missing: ${missingFieldsString}`;
|
||||
errorMsg = errMsg;
|
||||
}
|
||||
if (newPostContent.length === 0) {
|
||||
errorMsg = 'Your post has no content';
|
||||
}
|
||||
// In edit mode, allow updating even if content array is empty (legacy/minimal posts)
|
||||
|
||||
if (errorMsg) {
|
||||
dispatch(
|
||||
|
||||
@@ -309,9 +309,7 @@ export const CreatePostMinimal = ({
|
||||
const errMsg = `Missing: ${missingFieldsString}`;
|
||||
errorMsg = errMsg;
|
||||
}
|
||||
if (newPostContent.length === 0) {
|
||||
errorMsg = 'Your post has no content';
|
||||
}
|
||||
// In edit mode, allow updating even if content array is empty (legacy/minimal posts)
|
||||
|
||||
if (errorMsg) {
|
||||
dispatch(
|
||||
@@ -494,9 +492,7 @@ export const CreatePostMinimal = ({
|
||||
const errMsg = `Missing: ${missingFieldsString}`;
|
||||
errorMsg = errMsg;
|
||||
}
|
||||
if (newPostContent.length === 0) {
|
||||
errorMsg = 'Your post has no content';
|
||||
}
|
||||
// In edit mode, allow updating even if content array is empty (legacy/minimal posts)
|
||||
|
||||
if (errorMsg) {
|
||||
dispatch(
|
||||
|
||||
77
src/state/features/bookmarksSlice.ts
Normal file
77
src/state/features/bookmarksSlice.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export type BookmarkType = 'post' | 'blog' | 'link';
|
||||
export interface BookmarkItem {
|
||||
href: string; // router path like /:user/:blog or /:user/:blog/:postId
|
||||
title: string;
|
||||
created: number;
|
||||
type: BookmarkType;
|
||||
folder?: string; // optional grouping
|
||||
}
|
||||
|
||||
interface BookmarksState {
|
||||
bookmarksLocal: BookmarkItem[];
|
||||
}
|
||||
|
||||
const initialState: BookmarksState = {
|
||||
bookmarksLocal: [],
|
||||
};
|
||||
|
||||
const storageKey = (username?: string | null) =>
|
||||
`q-blog-bookmarks-${username && username.length ? username : 'guest'}`;
|
||||
|
||||
const bookmarksSlice = createSlice({
|
||||
name: 'bookmarks',
|
||||
initialState,
|
||||
reducers: {
|
||||
loadBookmarksForUser: (state, action: PayloadAction<{ username?: string | null }>) => {
|
||||
try {
|
||||
const key = storageKey(action.payload.username);
|
||||
const raw = localStorage.getItem(key);
|
||||
const arr: BookmarkItem[] = raw ? JSON.parse(raw) : [];
|
||||
state.bookmarksLocal = Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
state.bookmarksLocal = [];
|
||||
}
|
||||
},
|
||||
addBookmark: (
|
||||
state,
|
||||
action: PayloadAction<{ username?: string | null; item: BookmarkItem }>,
|
||||
) => {
|
||||
const { username, item } = action.payload;
|
||||
const exists = state.bookmarksLocal.some((b) => b.href === item.href);
|
||||
const next = exists
|
||||
? state.bookmarksLocal.map((b) => (b.href === item.href ? item : b))
|
||||
: [...state.bookmarksLocal, item];
|
||||
state.bookmarksLocal = next;
|
||||
try {
|
||||
localStorage.setItem(storageKey(username), JSON.stringify(next));
|
||||
} catch {}
|
||||
},
|
||||
setBookmarkFolder: (
|
||||
state,
|
||||
action: PayloadAction<{ username?: string | null; href: string; folder?: string }>,
|
||||
) => {
|
||||
const { username, href, folder } = action.payload;
|
||||
const next = state.bookmarksLocal.map((b) => (b.href === href ? { ...b, folder } : b));
|
||||
state.bookmarksLocal = next;
|
||||
try {
|
||||
localStorage.setItem(storageKey(username), JSON.stringify(next));
|
||||
} catch {}
|
||||
},
|
||||
removeBookmark: (
|
||||
state,
|
||||
action: PayloadAction<{ username?: string | null; href: string }>,
|
||||
) => {
|
||||
const { username, href } = action.payload;
|
||||
const next = state.bookmarksLocal.filter((b) => b.href !== href);
|
||||
state.bookmarksLocal = next;
|
||||
try {
|
||||
localStorage.setItem(storageKey(username), JSON.stringify(next));
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { loadBookmarksForUser, addBookmark, removeBookmark, setBookmarkFolder } = bookmarksSlice.actions;
|
||||
export default bookmarksSlice.reducer;
|
||||
@@ -5,6 +5,7 @@ import authReducer from './features/authSlice';
|
||||
import globalReducer from './features/globalSlice';
|
||||
import blogReducer from './features/blogSlice';
|
||||
import mailReducer from './features/mailSlice';
|
||||
import bookmarksReducer from './features/bookmarksSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -14,6 +15,7 @@ export const store = configureStore({
|
||||
global: globalReducer,
|
||||
blog: blogReducer,
|
||||
mail: mailReducer,
|
||||
bookmarks: bookmarksReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
|
||||
76
src/utils/migrateFavoritesToBookmarks.ts
Normal file
76
src/utils/migrateFavoritesToBookmarks.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import localforage from 'localforage';
|
||||
import { addBookmark, BookmarkItem } from '../state/features/bookmarksSlice';
|
||||
import { removePrefix } from '../utils/blogIdformats';
|
||||
|
||||
const FAVORITES_GLOBAL = 'q-blog-favorites';
|
||||
const FAVORITES_PREFIX = 'q-blog-favorites-';
|
||||
|
||||
interface FavoriteStoredItem {
|
||||
user: string;
|
||||
id: string; // identifier like q-blog-<blog>-post-<postId>
|
||||
}
|
||||
|
||||
function buildHref(item: FavoriteStoredItem): string | null {
|
||||
try {
|
||||
const id = item.id;
|
||||
if (!id || !id.includes('-post-')) return null;
|
||||
const [blogFull, postId] = id.split('-post-');
|
||||
const blogShort = removePrefix(blogFull);
|
||||
if (!blogShort || !postId || !item.user) return null;
|
||||
return `/${encodeURIComponent(item.user)}/${encodeURIComponent(blogShort)}/${encodeURIComponent(postId)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromInstance(
|
||||
instanceName: string,
|
||||
username: string | null | undefined,
|
||||
dispatch: (action: any) => void,
|
||||
): Promise<number> {
|
||||
const inst = localforage.createInstance({ name: instanceName });
|
||||
let count = 0;
|
||||
try {
|
||||
await inst.iterate<FavoriteStoredItem, void>((value, key, iterationNumber) => {
|
||||
if (!value || !value.id || !value.user) return;
|
||||
const href = buildHref(value);
|
||||
if (!href) return;
|
||||
const item: BookmarkItem = {
|
||||
href,
|
||||
title: value.id,
|
||||
created: Date.now(),
|
||||
type: 'post',
|
||||
};
|
||||
dispatch(addBookmark({ username, item }));
|
||||
count += 1;
|
||||
});
|
||||
// Clear after import
|
||||
await inst.clear();
|
||||
} catch {
|
||||
// ignore errors, treat as zero imported
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function migrateFavoritesToBookmarks(
|
||||
username: string | null | undefined,
|
||||
dispatch: (action: any) => void,
|
||||
): Promise<number> {
|
||||
const flagKey = `qblog_fav_migrated_${username || 'guest'}`;
|
||||
try {
|
||||
if (localStorage.getItem(flagKey) === '1') return 0;
|
||||
} catch {}
|
||||
|
||||
let total = 0;
|
||||
// Import from both legacy buckets
|
||||
total += await importFromInstance(FAVORITES_GLOBAL, username ?? null, dispatch);
|
||||
if (username) total += await importFromInstance(`${FAVORITES_PREFIX}${username}`, username, dispatch);
|
||||
|
||||
try {
|
||||
localStorage.setItem(flagKey, '1');
|
||||
} catch {}
|
||||
return total;
|
||||
}
|
||||
|
||||
export default migrateFavoritesToBookmarks;
|
||||
|
||||
@@ -8,6 +8,9 @@ import { RootState } from '../state/store';
|
||||
import PublishBlogModal from '../components/modals/PublishBlogModal';
|
||||
import EditBlogModal from '../components/modals/EditBlogModal';
|
||||
import NavBar from '../components/layout/Navbar/Navbar';
|
||||
import ScrollToTop from '../components/common/ScrollToTop';
|
||||
import { loadBookmarksForUser } from '../state/features/bookmarksSlice';
|
||||
import migrateFavoritesToBookmarks from '../utils/migrateFavoritesToBookmarks';
|
||||
|
||||
import {
|
||||
setCurrentBlog,
|
||||
@@ -64,6 +67,18 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
|
||||
})();
|
||||
}, [fullNotifications]);
|
||||
|
||||
// Load bookmarks and migrate legacy favorites into bookmarks on user change
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const username = user?.name || null;
|
||||
dispatch(loadBookmarksForUser({ username }));
|
||||
try {
|
||||
await migrateFavoritesToBookmarks(username, dispatch as any);
|
||||
dispatch(loadBookmarksForUser({ username }));
|
||||
} catch {}
|
||||
})();
|
||||
}, [user?.name, dispatch]);
|
||||
|
||||
const askForAccountInformation = useCallback(async () => {
|
||||
if (authStatus === 'loading') return;
|
||||
setAuthStatus('loading');
|
||||
@@ -187,6 +202,7 @@ const GlobalWrapper: React.FC<Props> = ({ children }) => {
|
||||
/>
|
||||
|
||||
{children}
|
||||
<ScrollToTop />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import NavBar from '@/components/layout/Navbar/Navbar';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from '@/state/store';
|
||||
@@ -20,7 +20,7 @@ const baseProps = {
|
||||
};
|
||||
|
||||
describe('Navbar multiblog', () => {
|
||||
it('shows Create Blog when user has 0 blogs', () => {
|
||||
it('shows Create Blog in main menu when user has 0 blogs', () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
@@ -30,10 +30,11 @@ describe('Navbar multiblog', () => {
|
||||
</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Menu'));
|
||||
expect(screen.getByText('Create Blog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows My Blogs dropdown when user has >=1 blogs', () => {
|
||||
it('shows My Blogs section in main menu when user has >=1 blogs', () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
@@ -46,6 +47,7 @@ describe('Navbar multiblog', () => {
|
||||
</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Menu'));
|
||||
expect(screen.getByText('My Blogs')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
29
tests/components/ScrollToTop.test.tsx
Normal file
29
tests/components/ScrollToTop.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from '@/state/store';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GlobalWrapper from '@/wrappers/GlobalWrapper';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
describe('ScrollToTop button', () => {
|
||||
it('appears after scrolling', async () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<MemoryRouter>
|
||||
<GlobalWrapper>
|
||||
<div style={{ height: 2000 }}>Tall content</div>
|
||||
</GlobalWrapper>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
// Simulate scroll
|
||||
Object.defineProperty(window, 'pageYOffset', { value: 300, writable: true });
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
expect(await screen.findByLabelText('Scroll to top')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
51
tests/features/ImageOverlay.test.tsx
Normal file
51
tests/features/ImageOverlay.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from '@/state/store';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { BlogIndividualPost } from '@/pages/BlogIndividualPost/BlogIndividualPost';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../msw/server';
|
||||
|
||||
describe('Image overlay', () => {
|
||||
it('opens lightbox when clicking post image', async () => {
|
||||
server.use(
|
||||
http.get('/arbitrary/BLOG/:user/:blog', ({ params }) =>
|
||||
HttpResponse.json({ title: 'My Blog', blogId: params.blog }),
|
||||
),
|
||||
http.get('/arbitrary/BLOG_POST/:user/:fullId', ({ params }) => {
|
||||
const { fullId, user } = params as any;
|
||||
if (typeof fullId === 'string' && fullId.includes('-post-') && user) {
|
||||
return HttpResponse.json({
|
||||
title: 'Post With Image',
|
||||
createdAt: 1,
|
||||
postContent: [
|
||||
{ type: 'image', id: 'img1', version: 1, content: { image: 'data:' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
return HttpResponse.json({}, { status: 404 });
|
||||
}),
|
||||
http.get('/arbitrary/resources/search', () => HttpResponse.json([])),
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<MemoryRouter initialEntries={[`/alice/myblog/123`]}>
|
||||
<Routes>
|
||||
<Route path="/:user/:blog/:postId" element={<BlogIndividualPost />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const img = await screen.findByRole('img', { name: /image/i });
|
||||
fireEvent.click(img);
|
||||
expect(await screen.findByTestId('image-lightbox-overlay')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
69
tests/pages/Bookmarks.test.tsx
Normal file
69
tests/pages/Bookmarks.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from '@/state/store';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import BookmarksPage from '@/pages/Bookmarks/Bookmarks';
|
||||
import { BlogIndividualPost } from '@/pages/BlogIndividualPost/BlogIndividualPost';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../msw/server';
|
||||
|
||||
describe('Bookmarks', () => {
|
||||
it('saves a post bookmark and shows it in the list', async () => {
|
||||
server.use(
|
||||
http.get('/arbitrary/BLOG/:user/:blog', ({ params }) =>
|
||||
HttpResponse.json({ title: 'My Blog', blogId: params.blog }),
|
||||
),
|
||||
http.get('/arbitrary/BLOG_POST/:user/:fullId', ({ params }) => {
|
||||
const { fullId, user } = params as any;
|
||||
if (typeof fullId === 'string' && fullId.includes('-post-') && user) {
|
||||
return HttpResponse.json({
|
||||
title: 'Bookmark Me',
|
||||
createdAt: 1,
|
||||
postContent: [
|
||||
{ type: 'editor', id: 'e1', version: 1, content: [{ text: 'Body' }] },
|
||||
],
|
||||
});
|
||||
}
|
||||
return HttpResponse.json({}, { status: 404 });
|
||||
}),
|
||||
http.get('/arbitrary/resources/search', () => HttpResponse.json([])),
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<MemoryRouter initialEntries={[`/alice/myblog/123`]}>
|
||||
<Routes>
|
||||
<Route path="/:user/:blog/:postId" element={<BlogIndividualPost />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Wait for post title
|
||||
expect(await screen.findByText('Bookmark Me')).toBeInTheDocument();
|
||||
// Click save to bookmarks
|
||||
const saveBtn = await screen.findByLabelText('Save to bookmarks');
|
||||
fireEvent.click(saveBtn);
|
||||
// Navigate to bookmarks within the same router and verify
|
||||
const { container } = render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<MemoryRouter initialEntries={[`/bookmarks`]}>
|
||||
<Routes>
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
// Assert within the Bookmarks render only
|
||||
expect(within(container).getByText('Bookmarks')).toBeInTheDocument();
|
||||
expect(within(container).getByText('Bookmark Me')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user