diff --git a/frontend/package.json b/frontend/package.json
index 00255f3..795b088 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
}
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 563609b..af76593 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -14,12 +14,15 @@ import RelatedSubsRow from './components/RelatedSubsRow';
import SourceToggle from './components/SourceToggle';
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,
@@ -275,6 +278,8 @@ function AppShell() {
useSyncUrlState(urlState);
+ const { mode, setMode } = useMode();
+
const currentSnapshot = useMemo(
() => createFeedSnapshot({ subreddit, authorView, sort, includeNsfw, mediaFilter, order, redditFilters }),
[subreddit, authorView, sort, includeNsfw, mediaFilter, order, redditFilters]
@@ -786,6 +791,14 @@ function AppShell() {
return { videos, images, audio, all: items.length };
}, [items]);
+ if (mode === 'feed') {
+ return (
+ setMode('grid')}>
+
+
+ );
+ }
+
return (
setAdvancedOpen(true)}
advancedFilterCount={countActiveFilters(advanced)}
+ onEnterFeed={() => setMode('feed')}
/>
{source === 'reddit' && (
diff --git a/frontend/src/components/FeedExitGesture.jsx b/frontend/src/components/FeedExitGesture.jsx
new file mode 100644
index 0000000..466dd07
--- /dev/null
+++ b/frontend/src/components/FeedExitGesture.jsx
@@ -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;
diff --git a/frontend/src/components/FeedItem.jsx b/frontend/src/components/FeedItem.jsx
new file mode 100644
index 0000000..dbb8461
--- /dev/null
+++ b/frontend/src/components/FeedItem.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (first.kind === 'image') {
+ return (
+
+

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

+
+ );
+ }
+
+ return null;
+}
+
+export default FeedItem;
diff --git a/frontend/src/components/FeedMode.jsx b/frontend/src/components/FeedMode.jsx
new file mode 100644
index 0000000..3197202
--- /dev/null
+++ b/frontend/src/components/FeedMode.jsx
@@ -0,0 +1,14 @@
+import FeedItem from './FeedItem';
+import '../styles/feed.css';
+
+function FeedMode({ items }) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
+
+export default FeedMode;
diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx
index 32ff88b..8ca27b2 100644
--- a/frontend/src/components/TopBar.jsx
+++ b/frontend/src/components/TopBar.jsx
@@ -93,7 +93,8 @@ function TopBar({
blueskySort,
onBlueskySortChange,
onOpenAdvanced,
- advancedFilterCount = 0
+ advancedFilterCount = 0,
+ onEnterFeed
}) {
const isEporner = source === 'eporner';
const isBooru = source === 'booru';
@@ -189,6 +190,14 @@ function TopBar({
)}
+ {onEnterFeed && (
+
+ )}
);
diff --git a/frontend/src/components/VideoPlayer.jsx b/frontend/src/components/VideoPlayer.jsx
index d708921..1c31dbb 100644
--- a/frontend/src/components/VideoPlayer.jsx
+++ b/frontend/src/components/VideoPlayer.jsx
@@ -33,6 +33,7 @@ const VideoPlayer = forwardRef(function VideoPlayer(
className,
autoPlay = true,
loop = true,
+ controls = true,
prebufferOnly = false,
allowUnmutedAutoplay = false,
preferredMuted = null,
@@ -565,7 +566,7 @@ const VideoPlayer = forwardRef(function VideoPlayer(