release: v0.3.0 — Bookmarks, lightbox, scroll-to-top, menu overhaul
Some checks failed
CI / build_test (push) Failing after 1m24s

This commit is contained in:
greenflame089
2025-08-30 07:34:20 -04:00
parent 0b100af686
commit 39d5c363a4
26 changed files with 1068 additions and 188 deletions

16
docs/CHANGELOG_v0.3.0.md Normal file
View File

@@ -0,0 +1,16 @@
QBlog v0.3.0 — Enhancements
- Image Lightbox: Click images in blog posts to open a fullwindow overlay for easier viewing. Close by clicking anywhere or pressing Escape.
- ScrollToTop Button: A floating button appears after you scroll down and returns you to the top smoothly when clicked.
- Bookmarks: Save links to QBlogs 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: QBlog logo, search bar, notifications bell, a Main Menu (Create Blog/Post, My Blogs, view toggle, Subscriptions, Bookmarks, Blocked Names, QMail), and the Name selector dropdown (for switching names only).
- `ResponsiveImage` now forwards `alt` to the underlying `<img>` for improved a11y.

View File

@@ -0,0 +1,39 @@
QBlog v0.3.0 — Enhancements and UX updates
Summary
- Image Lightbox: Click images inside posts to view them in a fullwindow overlay. Close the overlay by clicking anywhere or pressing Escape.
- ScrollToTop 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: QBlog logo, search, notifications, a unified Main Menu (Create Blog/Post, My Blogs, view toggle, Subscriptions, Bookmarks, Blocked Names, QMail), 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

View File

@@ -0,0 +1,14 @@
QBlog v0.3.0 — Whats New
- Image lightbox: Click any image in a post to view it fullwindow; click anywhere to dismiss.
- Scrolltotop: 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, QMail), 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, scrolltotop, 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, QMail.
- 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 QMail.

4
package-lock.json generated
View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "q-blog",
"private": true,
"version": "0.2.2",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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>

View 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;

View File

@@ -81,6 +81,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
<img
onLoad={() => setLoading(false)}
src={src}
alt={alt || ''}
style={{
width: '100%',
height: 'auto',

View 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;

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View 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;

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(

View 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;

View File

@@ -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({

View 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;

View File

@@ -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 />
</>
);
};

View File

@@ -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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});