Skip to content

Commit

Permalink
add mobile nav
Browse files Browse the repository at this point in the history
  • Loading branch information
zaknesler committed Jun 8, 2024
1 parent 647013a commit bbf1429
Show file tree
Hide file tree
Showing 10 changed files with 123 additions and 64 deletions.
33 changes: 28 additions & 5 deletions ui/src/components/entry/entry-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
HiSolidBookmark,
} from 'solid-icons/hi';
import type { Component } from 'solid-js';
import { useEntries } from '~/contexts/entries-context';
import { useEntryRead } from '~/hooks/queries/use-entry-read';
import { useEntrySaved } from '~/hooks/queries/use-entry-saved';
import type { Entry } from '~/types/bindings';
Expand All @@ -17,6 +18,7 @@ type EntryActionsProps = {
};

export const EntryActions: Component<EntryActionsProps> = props => {
const entries = useEntries();
const entryRead = useEntryRead();
const entrySaved = useEntrySaved();

Expand All @@ -30,6 +32,14 @@ export const EntryActions: Component<EntryActionsProps> = props => {
action(props.entry.uuid, props.entry.feed_uuid);
};

const handleNavigateBack = () => {
entries.nav.maybeNavigate('back');
};

const handleNavigateNext = () => {
entries.nav.maybeNavigate('next');
};

return (
<div
class={cx(
Expand All @@ -38,22 +48,35 @@ export const EntryActions: Component<EntryActionsProps> = props => {
)}
>
<ActionButton
showCircle={!props.entry.read_at}
onClick={handleToggleRead}
icon={HiOutlineEnvelope}
tooltip={props.entry.read_at ? 'Mark as unread' : 'Mark as read'}
showCircle={!props.entry.read_at}
class="p-2 md:p-1"
onClick={handleToggleRead}
/>

<ActionButton
onClick={handleToggleSaved}
icon={props.entry.saved_at ? HiSolidBookmark : HiOutlineBookmark}
tooltip={props.entry.saved_at ? 'Mark as unsaved' : 'Mark as saved'}
class="p-2 md:p-1"
onClick={handleToggleSaved}
/>

<ActionButton icon={HiSolidArrowLeft} tooltip="View previous item" class="ml-auto p-2 md:hidden md:p-1" />
<ActionButton icon={HiSolidArrowRight} tooltip="View next item" class="p-2 md:hidden md:p-1" />
<ActionButton
onClick={handleNavigateBack}
disabled={!entries.nav.canGoBack()}
icon={HiSolidArrowLeft}
tooltip="View previous item"
class="ml-auto p-2 md:hidden md:p-1"
/>

<ActionButton
onClick={handleNavigateNext}
disabled={!entries.nav.canGoForward()}
icon={HiSolidArrowRight}
tooltip="View next item"
class="p-2 md:hidden md:p-1"
/>
</div>
);
};
35 changes: 11 additions & 24 deletions ui/src/components/entry/entry-list.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { createActiveElement } from '@solid-primitives/active-element';
import { type NullableBounds, createElementBounds } from '@solid-primitives/bounds';
import { HiOutlineInbox } from 'solid-icons/hi';
import { type Component, For, Match, Show, Switch, createEffect, createSignal } from 'solid-js';
import { IDS } from '~/constants/elements';
import { useEntries } from '~/contexts/entries-context';
import { useFeeds } from '~/hooks/queries/use-feeds';
import { useInfiniteEntries } from '~/hooks/queries/use-infinite-entries';
import { useListNav } from '~/hooks/use-list-nav';
import { Empty } from '../ui/empty';
import { Spinner } from '../ui/spinner';
import { EntryItem } from './entry-item';
Expand All @@ -18,53 +15,43 @@ export const EntryList: Component<EntryListProps> = props => {
const [bottomOfList, setBottomOfList] = createSignal<HTMLElement>();
const listBounds = createElementBounds(bottomOfList);

const { feeds } = useFeeds();
const entries = useInfiniteEntries();

// If the user is focused within the entry content, don't respond to arrow keys
const activeElement = createActiveElement();
const isFocusedWithinEntry = () => !!document.getElementById(IDS.ARTICLE)?.contains(activeElement());

// Handle arrow navigation
useListNav(() => ({
enabled: !isFocusedWithinEntry(),
entries: entries.allEntries(),
}));
const feeds = useFeeds();
const entries = useEntries();

createEffect(() => {
if (!listBounds.bottom || !props.containerBounds?.bottom) return;

const bottomOfListVisible = listBounds.bottom * 0.9 <= props.containerBounds.bottom;
if (!bottomOfListVisible) return;

entries.fetchMore();
entries.data.fetchMore();
});

return (
<Switch>
<Match when={entries.query.isPending}>
<Match when={entries.data.query.isPending}>
<div class="size-full flex-1 p-4">
<Empty>
<Spinner />
</Empty>
</div>
</Match>

<Match when={entries.query.isError}>
<p class="p-4">Error: {entries.query.error?.message}</p>
<Match when={entries.data.query.isError}>
<p class="p-4">Error: {entries.data.query.error?.message}</p>
</Match>

<Match when={entries.query.isSuccess && feeds.data}>
<Match when={entries.data.query.isSuccess && feeds.query.data}>
<Show
when={entries.allEntries().length}
when={entries.data.allEntries().length}
fallback={
<div class="size-full flex-1 p-4">
<Empty icon={HiOutlineInbox} text="No items to display" />
</div>
}
>
<div class="flex flex-col gap-2 px-4 py-2">
<For each={entries.allEntries()}>
<For each={entries.data.allEntries()}>
{(entry, index) => (
<EntryItem
tabIndex={index() === 0 ? 0 : -1} // Disable tabindex so we can override it with arrow keys
Expand All @@ -75,7 +62,7 @@ export const EntryList: Component<EntryListProps> = props => {

<div ref={setBottomOfList} class="-mt-1" />

<Show when={entries.query.isFetchingNextPage}>
<Show when={entries.data.query.isFetchingNextPage}>
<div class="flex w-full items-center justify-center p-4">
<Spinner />
</div>
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/feed/feed-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ export const FeedItem: Component<FeedItemProps> = props => {
const state = useQueryState();
const location = useLocation();

const { stats } = useFeedsStats();
const stats = useFeedsStats();
const notifications = useNotifications();

const getPath = createMemo(() => `/feeds/${props.feed.uuid}`);
const isActive = createMemo(() => location.pathname.startsWith(getPath()));
const getStats = createMemo(() => stats.data?.find(item => item.uuid === props.feed.uuid));
const getStats = createMemo(() => stats.query.data?.find(item => item.uuid === props.feed.uuid));

const getFaviconSrc = () => props.feed.favicon_b64 || props.feed.favicon_url;

Expand Down
16 changes: 8 additions & 8 deletions ui/src/components/feed/feed-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const FeedList = () => {
const state = useQueryState();
const location = useLocation();

const { feeds } = useFeeds();
const { totalStats } = useFeedsStats();
const feeds = useFeeds();
const stats = useFeedsStats();

return (
<div class="flex w-full flex-col gap-4 p-4 xl:p-0">
Expand All @@ -21,7 +21,7 @@ export const FeedList = () => {
title="All feeds"
icon={() => <HiOutlineSquare3Stack3d class="size-6 text-gray-600 md:size-5 dark:text-gray-500" />}
active={location.pathname === '/'}
unread_count={totalStats()?.count_unread}
unread_count={stats.total()?.count_unread}
/>

<div class="flex w-full flex-col gap-1">
Expand All @@ -31,13 +31,13 @@ export const FeedList = () => {

<FeedFolder slug="photography" label="Photography">
<Switch>
<Match when={feeds.isError}>
<p>Error: {feeds.error?.message}</p>
<Match when={feeds.query.isError}>
<p>Error: {feeds.query.error?.message}</p>
</Match>

<Match when={feeds.isSuccess}>
<Show when={feeds.data?.length} fallback={<div>No feeds.</div>}>
<For each={feeds.data}>{feed => <FeedItem feed={feed} />}</For>
<Match when={feeds.query.isSuccess}>
<Show when={feeds.query.data?.length} fallback={<div>No feeds.</div>}>
<For each={feeds.query.data}>{feed => <FeedItem feed={feed} />}</For>
</Show>
</Match>
</Switch>
Expand Down
1 change: 1 addition & 0 deletions ui/src/constants/ui/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const action = cva(
'hover:bg-gray-200',
'focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-opacity-30',
'dark:hover:bg-gray-700 dark:text-gray-400 dark:focus:ring-gray-600',
'disabled:pointer-events-none disabled:opacity-25',
],
{
variants: {
Expand Down
34 changes: 34 additions & 0 deletions ui/src/contexts/entries-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createActiveElement } from '@solid-primitives/active-element';
import { createContext, useContext } from 'solid-js';
import { IDS } from '~/constants/elements';
import { useInfiniteEntries } from '~/hooks/queries/use-infinite-entries';
import { useListNav } from '~/hooks/use-list-nav';

type EntriesContext = ReturnType<typeof makeEntriesContext>;
export const EntriesContext = createContext<EntriesContext>();

export const useEntries = () => {
const state = useContext(EntriesContext);
if (!state) throw new Error('EntriesContext has not been initialized.');
return state;
};

export const makeEntriesContext = () => {
const data = useInfiniteEntries();

// If the user is focused within the entry content, don't respond to arrow keys
const activeElement = createActiveElement();
const isFocusedWithinEntry = () => !!document.getElementById(IDS.ARTICLE)?.contains(activeElement());

// Handle arrow navigation
const nav = useListNav(() => ({
enabled: !isFocusedWithinEntry(),
entryUuids: data.allEntries().map(entry => entry.uuid),
fetchMore: data.fetchMore,
}));

return {
data,
nav,
};
};
10 changes: 5 additions & 5 deletions ui/src/hooks/queries/use-feeds-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { getFeedStats } from '~/api/feeds';
import { QUERY_KEYS } from '~/constants/query';

export const useFeedsStats = () => {
const stats = createQuery(() => ({
const query = createQuery(() => ({
queryKey: [QUERY_KEYS.FEEDS_STATS],
queryFn: getFeedStats,
refetchOnWindowFocus: false,
refetchOnMount: false,
}));

const totalStats = () =>
stats.data?.reduce(
const total = () =>
query.data?.reduce(
(acc, stat) => ({
count_total: acc.count_total + stat.count_total,
count_unread: acc.count_unread + stat.count_unread,
Expand All @@ -21,7 +21,7 @@ export const useFeedsStats = () => {
);

return {
stats,
totalStats,
query,
total,
};
};
6 changes: 3 additions & 3 deletions ui/src/hooks/queries/use-feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import { getFeeds } from '~/api/feeds';
import { QUERY_KEYS } from '~/constants/query';

export const useFeeds = () => {
const feeds = createQuery(() => ({
const query = createQuery(() => ({
queryKey: [QUERY_KEYS.FEEDS],
queryFn: getFeeds,
refetchOnWindowFocus: false,
refetchOnMount: false,
}));

const findFeed = (feed_uuid: string) => feeds.data?.find(feed => feed.uuid === feed_uuid);
const findFeed = (feed_uuid: string) => query.data?.find(feed => feed.uuid === feed_uuid);

return {
feeds,
query,
findFeed,
};
};
28 changes: 19 additions & 9 deletions ui/src/hooks/use-list-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { useNavigate } from '@solidjs/router';
import { useQueryClient } from '@tanstack/solid-query';
import { createEffect } from 'solid-js';
import { QUERY_KEYS } from '~/constants/query';
import type { Entry } from '~/types/bindings';
import { findEntryItem } from '~/utils/entries';
import { useQueryState } from '../contexts/query-state-context';
import { useViewport } from '../contexts/viewport-context';

type UseListNavParams = {
enabled: boolean;
entries: Entry[];
entryUuids: string[];
fetchMore: () => void;
};

export const useListNav = (params: () => UseListNavParams) => {
Expand All @@ -23,7 +23,7 @@ export const useListNav = (params: () => UseListNavParams) => {
const queryClient = useQueryClient();

createEffect(() => {
if (!params().entries.length || viewport.lteBreakpoint('md') || !params().enabled) return;
if (!params().enabled || !params().entryUuids.length || viewport.lteBreakpoint('md')) return;

const e = keyDownEvent();
if (!e) return;
Expand All @@ -41,23 +41,33 @@ export const useListNav = (params: () => UseListNavParams) => {
}
});

const getCurrentIndex = () => params().entryUuids.findIndex(uuid => uuid === state.params.entry_uuid);

const canGoBack = () => getCurrentIndex() > 0;
const canGoForward = () => getCurrentIndex() < params().entryUuids.length;

const maybeNavigate = debounce((direction: 'next' | 'back') => {
const currentIndex = params().entries.findIndex(entry => entry.uuid === state.params.entry_uuid);
const currentIndex = getCurrentIndex();

const offset = direction === 'next' ? -1 : 1;
const entry = params().entries[currentIndex + offset];
if (!entry) return;
const offset = direction === 'back' ? -1 : 1;
const entry_uuid = params().entryUuids[currentIndex + offset];
if (!entry_uuid) return;

const activeItem = findEntryItem(entry.uuid);
const activeItem = findEntryItem(entry_uuid);
if (activeItem) activeItem.focus();

// Cancel the current entry view request
queryClient.cancelQueries({ queryKey: [QUERY_KEYS.ENTRIES_VIEW, state.params.entry_uuid] });

navigate(state.getEntryUrl(entry.uuid));
// Maybe fetch more if we're nearing the end of the list
if (currentIndex > params().entryUuids.length - 3) params().fetchMore();

navigate(state.getEntryUrl(entry_uuid));
}, 30);

return {
canGoBack,
canGoForward,
maybeNavigate,
};
};
Loading

0 comments on commit bbf1429

Please sign in to comment.