Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"vite": "^5.4.10",
"vite-plugin-mkcert": "^2.0.0",
"vitest": "^4.1.7"
}
}
14 changes: 14 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@
import ErrorBoundary from './components/ErrorBoundary';
import MediaTypeToggle from './components/MediaTypeToggle';
import RelatedSubsRow from './components/RelatedSubsRow';
import SourceToggle from './components/SourceToggle';

Check warning on line 14 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

'SourceToggle' is defined but never used

Check warning on line 14 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

'SourceToggle' is defined but never used
import AdvancedSearch, { DEFAULT_ADVANCED, QUALITY_THRESHOLDS, countActiveFilters } from './components/AdvancedSearch';
import ActiveFiltersStrip from './components/ActiveFiltersStrip';
import FeedMode from './components/FeedMode';
import FeedExitGesture from './components/FeedExitGesture';
import { ToastProvider, useToast } from './components/Toast';
import { useFavorites } from './hooks/useFavorites';
import { useSavedSubreddits } from './hooks/useSavedSubreddits';
import { useHiddenAuthors } from './hooks/useBlockList';
import { usePreferences } from './hooks/usePreferences';
import { getInitialUrlState, useSyncUrlState } from './hooks/useUrlState';
import { useMode } from './hooks/useMode';
import {
fetchRedditUserMedia,
fetchSubredditMedia,
Expand Down Expand Up @@ -271,10 +274,12 @@
score: redditFilters.minScore,
hosted: redditFilters.onlyRedditHosted ? '1' : '',
dup: redditFilters.suppressDuplicates ? '' : '0'
}), [subreddit, authorView, sort, mediaFilter, order, redditFilters]);

Check warning on line 277 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

React Hook useMemo has missing dependencies: 'blueskyQuery', 'booruQuery', 'booruSite', 'coomerQuery', 'epornerQuery', 'source', 'xvideosQuery', and 'youtubeQuery'. Either include them or remove the dependency array

Check warning on line 277 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

React Hook useMemo has missing dependencies: 'blueskyQuery', 'booruQuery', 'booruSite', 'coomerQuery', 'epornerQuery', 'source', 'xvideosQuery', and 'youtubeQuery'. Either include them or remove the dependency array

useSyncUrlState(urlState);

const { mode, setMode } = useMode();

const currentSnapshot = useMemo(
() => createFeedSnapshot({ subreddit, authorView, sort, includeNsfw, mediaFilter, order, redditFilters }),
[subreddit, authorView, sort, includeNsfw, mediaFilter, order, redditFilters]
Expand Down Expand Up @@ -625,7 +630,7 @@
} finally {
setLoadingMore(false);
}
}, [source, epornerQuery, epornerOrder, booruSite, booruQuery, youtubeQuery, youtubeOrder, coomerQuery, coomerSort, blueskyQuery, blueskySort, xvideosQuery, mediaFilter, after, loadingMore, authorView, sort, includeNsfw, subreddit, redditFilters, advanced.include, advanced.exclude, advanced.performers]);

Check warning on line 633 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

React Hook useCallback has unnecessary dependencies: 'blueskyQuery', 'blueskySort', 'booruQuery', 'booruSite', and 'xvideosQuery'. Either exclude them or remove the dependency array

Check warning on line 633 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

React Hook useCallback has unnecessary dependencies: 'blueskyQuery', 'blueskySort', 'booruQuery', 'booruSite', and 'xvideosQuery'. Either exclude them or remove the dependency array

useEffect(() => {
const node = loadMoreSentinelRef.current;
Expand Down Expand Up @@ -704,7 +709,7 @@
setSubreddit(multireddit);
}

function handleSelectAuthor(username) {

Check warning on line 712 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

'handleSelectAuthor' is defined but never used

Check warning on line 712 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

'handleSelectAuthor' is defined but never used
setAuthorView({ source: 'reddit', username });
setIncludeNsfw(true);
setActivePost(null);
Expand Down Expand Up @@ -786,6 +791,14 @@
return { videos, images, audio, all: items.length };
}, [items]);

if (mode === 'feed') {
return (
<FeedExitGesture onExit={() => setMode('grid')}>
<FeedMode items={displayItems} />
</FeedExitGesture>
);
}

return (
<div className="app-shell-beeg">
<TopBar
Expand Down Expand Up @@ -826,6 +839,7 @@
onBlueskySortChange={setBlueskySort}
onOpenAdvanced={() => setAdvancedOpen(true)}
advancedFilterCount={countActiveFilters(advanced)}
onEnterFeed={() => setMode('feed')}
/>

{source === 'reddit' && (
Expand Down Expand Up @@ -866,7 +880,7 @@

{authorView && (
<div className="state-box state-box-author">
Viewing u/{authorView.username}'s posts

Check warning on line 883 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

`'` can be escaped with `&apos;`, `&lsquo;`, `&#39;`, `&rsquo;`

Check warning on line 883 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / build

`'` can be escaped with `&apos;`, `&lsquo;`, `&#39;`, `&rsquo;`
<button type="button" className="state-box-back" onClick={() => setAuthorView(null)}>Back to feed</button>
</div>
)}
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/components/FeedExitGesture.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect } from 'react';

const EDGE_SWIPE_START_THRESHOLD_PX = 25;
const EDGE_SWIPE_DISTANCE_THRESHOLD_PX = 60;
const EDGE_SWIPE_VERTICAL_TOLERANCE_PX = 40;

function FeedExitGesture({ onExit, children }) {
useEffect(() => {
let touchStart = null;

function handleKeyDown(e) {
if (e.key === 'Escape') {
e.preventDefault();
window.history.back();
}
}

function handlePopState() {
const urlMode = new URLSearchParams(window.location.search).get('mode');
if (urlMode !== 'feed') {
onExit();
}
}

function handleTouchStart(e) {
const t = e.touches[0];
if (!t) return;
if (t.clientX <= EDGE_SWIPE_START_THRESHOLD_PX) {
touchStart = { x: t.clientX, y: t.clientY };
} else {
touchStart = null;
}
}

function handleTouchMove(e) {
if (!touchStart) return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - touchStart.x;
const dy = Math.abs(t.clientY - touchStart.y);
if (
dx > EDGE_SWIPE_DISTANCE_THRESHOLD_PX &&
dy < EDGE_SWIPE_VERTICAL_TOLERANCE_PX
) {
touchStart = null;
window.history.back();
}
}

function handleTouchEnd() {
touchStart = null;
}

window.addEventListener('keydown', handleKeyDown);
window.addEventListener('popstate', handlePopState);
window.addEventListener('touchstart', handleTouchStart, { passive: true });
window.addEventListener('touchmove', handleTouchMove, { passive: true });
window.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('popstate', handlePopState);
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
}, [onExit]);

return <>{children}</>;
}

export default FeedExitGesture;
49 changes: 49 additions & 0 deletions frontend/src/components/FeedItem.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import VideoPlayer from './VideoPlayer';
import { getModalItems } from '../utils/media';

function FeedItem({ item }) {
const modalItems = getModalItems(item);
const first = modalItems[0];
if (!first) return null;

if (first.kind === 'video') {
return (
<div className="feed-item">
<VideoPlayer
mp4Url={first.url}
hlsUrl={first.hlsUrl}
dashUrl={first.dashUrl}
hasAudio={first.hasAudio}
sourceKind={first.sourceKind}
posterUrl={item?.thumbnail || ''}
className="feed-item__video"
controls={false}
/>
</div>
);
}

if (first.kind === 'image') {
return (
<div className="feed-item">
<img src={first.url} alt="" className="feed-item__media" />
</div>
);
}

if (first.kind === 'audio' || first.kind === 'embed') {
return null;
}

if (first.url) {
return (
<div className="feed-item">
<img src={first.url} alt="" className="feed-item__media" />
</div>
);
}

return null;
}

export default FeedItem;
14 changes: 14 additions & 0 deletions frontend/src/components/FeedMode.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import FeedItem from './FeedItem';
import '../styles/feed.css';

function FeedMode({ items }) {
return (
<div className="feed-mode" role="region" aria-label="Feed mode">
{items.map((item) => (
<FeedItem key={item.id} item={item} />
))}
</div>
);
}

export default FeedMode;
11 changes: 10 additions & 1 deletion frontend/src/components/TopBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ function TopBar({
blueskySort,
onBlueskySortChange,
onOpenAdvanced,
advancedFilterCount = 0
advancedFilterCount = 0,
onEnterFeed
}) {
const isEporner = source === 'eporner';
const isBooru = source === 'booru';
Expand Down Expand Up @@ -189,6 +190,14 @@ function TopBar({
<ThemeIcon theme={theme} />
</button>
)}
{onEnterFeed && (
<button type="button" className="topbar-icon-btn" onClick={onEnterFeed} aria-label="Enter feed mode" title="Enter feed">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="6" y="3" width="12" height="7" rx="1.5" />
<rect x="6" y="14" width="12" height="7" rx="1.5" />
</svg>
</button>
)}
</div>
</header>
);
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/VideoPlayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const VideoPlayer = forwardRef(function VideoPlayer(
className,
autoPlay = true,
loop = true,
controls = true,
prebufferOnly = false,
allowUnmutedAutoplay = false,
preferredMuted = null,
Expand Down Expand Up @@ -565,7 +566,7 @@ const VideoPlayer = forwardRef(function VideoPlayer(
<div ref={wrapperRef} className="video-player-shell" style={wrapperStyle}>
<video
ref={videoRef}
controls
controls={controls}
playsInline
preload="metadata"
className={className}
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/hooks/useMode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useRef, useState } from 'react';

const MODE_KEY = 'nightfeed:mode';
const VALID_MODES = new Set(['grid', 'feed']);

function readInitialMode() {
if (typeof window === 'undefined') return 'grid';
const url = new URLSearchParams(window.location.search).get('mode');
if (VALID_MODES.has(url)) return url;
try {
const stored = localStorage.getItem(MODE_KEY);
if (VALID_MODES.has(stored)) return stored;
} catch {
// localStorage may be unavailable; fall through to default.
}
return 'grid';
}

function buildUrl(params) {
const search = params.toString();
return `${window.location.pathname}${search ? '?' + search : ''}${window.location.hash}`;
}

export function useMode() {
const [mode, setModeState] = useState(readInitialMode);
const modeRef = useRef(mode);
const gridScrollYRef = useRef(0);

// Defensive: keep the ref aligned with state in case state ever changes
// through a path other than setMode (it shouldn't, but the ref is the
// synchronous source of truth and we don't want it to drift).
useEffect(() => {
modeRef.current = mode;
}, [mode]);

useEffect(() => {
try {
localStorage.setItem(MODE_KEY, mode);
} catch {
// ignore
}
}, [mode]);

// Cold-load history bootstrap: if we landed in feed mode directly (URL or
// localStorage) and the current history entry was not pushed by us, insert
// a grid entry below the current one so browser-back exits feed instead of
// leaving the app.
useEffect(() => {
if (mode !== 'feed') return;
if (window.history.state?.nightfeedMode === 'feed') return;
const params = new URLSearchParams(window.location.search);
params.delete('mode');
const gridUrl = buildUrl(params);
params.set('mode', 'feed');
const feedUrl = buildUrl(params);
window.history.replaceState(
{ ...window.history.state, nightfeedMode: 'grid' },
'',
gridUrl
);
window.history.pushState({ nightfeedMode: 'feed' }, '', feedUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Restore scroll when returning to grid from feed.
useEffect(() => {
if (mode !== 'grid') return;
const y = gridScrollYRef.current;
if (y <= 0) return;
requestAnimationFrame(() => window.scrollTo(0, y));
}, [mode]);

const setMode = useCallback((next) => {
if (!VALID_MODES.has(next)) return;
if (modeRef.current === next) return;

const params = new URLSearchParams(window.location.search);
if (next === 'feed') {
gridScrollYRef.current = window.scrollY;
params.set('mode', 'feed');
window.history.pushState(
{ nightfeedMode: 'feed' },
'',
buildUrl(params)
);
} else {
params.delete('mode');
window.history.replaceState(
{ ...window.history.state, nightfeedMode: 'grid' },
'',
buildUrl(params)
);
}
modeRef.current = next;
setModeState(next);
}, []);

return { mode, setMode };
}
Loading
Loading