file ordering
This commit is contained in:
176
src/tabs/Feed/FeedDetail.tsx
Normal file
176
src/tabs/Feed/FeedDetail.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Feed detail view component for PodTUI
|
||||
* Shows podcast info and episode list
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface FeedDetailProps {
|
||||
feed: Feed;
|
||||
focused?: boolean;
|
||||
onBack?: () => void;
|
||||
onPlayEpisode?: (episode: Episode) => void;
|
||||
}
|
||||
|
||||
export function FeedDetail(props: FeedDetailProps) {
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [showInfo, setShowInfo] = createSignal(true);
|
||||
|
||||
const episodes = () => {
|
||||
// Sort episodes by publication date (newest first)
|
||||
return [...props.feed.episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs > 0) {
|
||||
return `${hrs}h ${mins % 60}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
const eps = episodes();
|
||||
|
||||
if (key.name === "escape" && props.onBack) {
|
||||
props.onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "i") {
|
||||
setShowInfo((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const episode = eps[selectedIndex()];
|
||||
if (episode && props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode);
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(eps.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return;
|
||||
handleKeyPress(key);
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with back button */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box border padding={0} onMouseDown={props.onBack}>
|
||||
<text fg="cyan">[Esc] Back</text>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)}>
|
||||
<text fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Podcast info section */}
|
||||
<Show when={showInfo()}>
|
||||
<box border padding={1} flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</text>
|
||||
{props.feed.podcast.author && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">by</text>
|
||||
<text fg="cyan">{props.feed.podcast.author}</text>
|
||||
</box>
|
||||
)}
|
||||
<box height={1} />
|
||||
<text fg="gray">
|
||||
{props.feed.podcast.description?.slice(0, 200)}
|
||||
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Episodes:</text>
|
||||
<text fg="white">{props.feed.episodes.length}</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Updated:</text>
|
||||
<text fg="white">{formatDate(props.feed.lastUpdated)}</text>
|
||||
</box>
|
||||
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||
</text>
|
||||
{props.feed.isPinned && <text fg="yellow">[Pinned]</text>}
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Episodes header */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<strong>Episodes</strong>
|
||||
</text>
|
||||
<text fg="gray">({episodes().length} total)</text>
|
||||
</box>
|
||||
|
||||
{/* Episode list */}
|
||||
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
||||
<For each={episodes()}>
|
||||
{(episode, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={1}
|
||||
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
if (props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||
{index() === selectedIndex() ? ">" : " "}
|
||||
</text>
|
||||
<text fg={index() === selectedIndex() ? "white" : undefined}>
|
||||
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
||||
{episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
|
||||
{/* Help text */}
|
||||
<text fg="gray">
|
||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
177
src/tabs/Feed/FeedFilter.tsx
Normal file
177
src/tabs/Feed/FeedFilter.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Feed filter component for PodTUI
|
||||
* Toggle and filter options for feed list
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||
import type { FeedFilter } from "@/types/feed";
|
||||
|
||||
interface FeedFilterProps {
|
||||
filter: FeedFilter;
|
||||
focused?: boolean;
|
||||
onFilterChange: (filter: FeedFilter) => void;
|
||||
}
|
||||
|
||||
type FilterField = "visibility" | "sort" | "pinned" | "search";
|
||||
|
||||
export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
|
||||
const [searchValue, setSearchValue] = createSignal(
|
||||
props.filter.searchQuery || "",
|
||||
);
|
||||
|
||||
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"];
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField());
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length;
|
||||
setFocusField(fields[nextIndex]);
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "visibility") {
|
||||
cycleVisibility();
|
||||
} else if (focusField() === "sort") {
|
||||
cycleSort();
|
||||
} else if (focusField() === "pinned") {
|
||||
togglePinned();
|
||||
}
|
||||
} else if (key.name === "space") {
|
||||
if (focusField() === "pinned") {
|
||||
togglePinned();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cycleVisibility = () => {
|
||||
const current = props.filter.visibility;
|
||||
let next: FeedVisibility | "all";
|
||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
||||
else next = "all";
|
||||
props.onFilterChange({ ...props.filter, visibility: next });
|
||||
};
|
||||
|
||||
const cycleSort = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
];
|
||||
const currentIndex = sortOptions.indexOf(
|
||||
props.filter.sortBy as FeedSortField,
|
||||
);
|
||||
const nextIndex = (currentIndex + 1) % sortOptions.length;
|
||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
|
||||
};
|
||||
|
||||
const togglePinned = () => {
|
||||
props.onFilterChange({
|
||||
...props.filter,
|
||||
pinnedOnly: !props.filter.pinnedOnly,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchInput = (value: string) => {
|
||||
setSearchValue(value);
|
||||
props.onFilterChange({ ...props.filter, searchQuery: value });
|
||||
};
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = props.filter.visibility;
|
||||
if (vis === "all") return "All";
|
||||
if (vis === "public") return "Public";
|
||||
return "Private";
|
||||
};
|
||||
|
||||
const visibilityColor = () => {
|
||||
const vis = props.filter.visibility;
|
||||
if (vis === "public") return "green";
|
||||
if (vis === "private") return "yellow";
|
||||
return "white";
|
||||
};
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = props.filter.sortBy;
|
||||
switch (sort) {
|
||||
case "title":
|
||||
return "Title";
|
||||
case "episodeCount":
|
||||
return "Episodes";
|
||||
case "latestEpisode":
|
||||
return "Latest";
|
||||
case "updated":
|
||||
default:
|
||||
return "Updated";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1}>
|
||||
<text>
|
||||
<strong>Filter Feeds</strong>
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
||||
{/* Visibility filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>
|
||||
Show:
|
||||
</text>
|
||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Sort filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "sort" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "sort" ? "cyan" : "gray"}>Sort:</text>
|
||||
<text fg="white">{sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Pinned filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>
|
||||
Pinned:
|
||||
</text>
|
||||
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Search box */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "search" ? "cyan" : "gray"}>Search:</text>
|
||||
<input
|
||||
value={searchValue()}
|
||||
onInput={handleSearchInput}
|
||||
placeholder="Filter by name..."
|
||||
focused={props.focused && focusField() === "search"}
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
107
src/tabs/Feed/FeedItem.tsx
Normal file
107
src/tabs/Feed/FeedItem.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Feed item component for PodTUI
|
||||
* Displays a single feed/podcast in the list
|
||||
*/
|
||||
|
||||
import type { Feed, FeedVisibility } from "@/types/feed";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface FeedItemProps {
|
||||
feed: Feed;
|
||||
isSelected: boolean;
|
||||
showEpisodeCount?: boolean;
|
||||
showLastUpdated?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function FeedItem(props: FeedItemProps) {
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d");
|
||||
};
|
||||
|
||||
const episodeCount = () => props.feed.episodes.length;
|
||||
const unplayedCount = () => {
|
||||
// This would be calculated based on episode status
|
||||
return props.feed.episodes.length;
|
||||
};
|
||||
|
||||
const visibilityIcon = () => {
|
||||
return props.feed.visibility === "public" ? "[P]" : "[*]";
|
||||
};
|
||||
|
||||
const visibilityColor = () => {
|
||||
return props.feed.visibility === "public" ? "green" : "yellow";
|
||||
};
|
||||
|
||||
const pinnedIndicator = () => {
|
||||
return props.feed.isPinned ? "*" : " ";
|
||||
};
|
||||
|
||||
if (props.compact) {
|
||||
// Compact single-line view
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={props.isSelected ? "#333" : undefined}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</text>
|
||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||
<text fg={props.isSelected ? "white" : undefined}>
|
||||
{props.feed.customName || props.feed.podcast.title}
|
||||
</text>
|
||||
{props.showEpisodeCount && <text fg="gray">({episodeCount()})</text>}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// Full view with details
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
border={props.isSelected}
|
||||
borderColor={props.isSelected ? "cyan" : undefined}
|
||||
backgroundColor={props.isSelected ? "#222" : undefined}
|
||||
padding={1}
|
||||
>
|
||||
{/* Title row */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</text>
|
||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||
<text fg="yellow">{pinnedIndicator()}</text>
|
||||
<text fg={props.isSelected ? "white" : undefined}>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Details row */}
|
||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
||||
{props.showEpisodeCount && (
|
||||
<text fg="gray">
|
||||
{episodeCount()} episodes ({unplayedCount()} new)
|
||||
</text>
|
||||
)}
|
||||
{props.showLastUpdated && (
|
||||
<text fg="gray">Updated: {formatDate(props.feed.lastUpdated)}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{/* Description (truncated) */}
|
||||
{props.feed.podcast.description && (
|
||||
<box paddingLeft={4} paddingTop={0}>
|
||||
<text fg="gray">
|
||||
{props.feed.podcast.description.slice(0, 60)}
|
||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
189
src/tabs/Feed/FeedList.tsx
Normal file
189
src/tabs/Feed/FeedList.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Feed list component for PodTUI
|
||||
* Scrollable list of feeds with keyboard navigation and mouse support
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { FeedItem } from "./FeedItem";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||
import type { Feed } from "@/types/feed";
|
||||
|
||||
interface FeedListProps {
|
||||
focused?: boolean;
|
||||
compact?: boolean;
|
||||
showEpisodeCount?: boolean;
|
||||
showLastUpdated?: boolean;
|
||||
onSelectFeed?: (feed: Feed) => void;
|
||||
onOpenFeed?: (feed: Feed) => void;
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
export function FeedList(props: FeedListProps) {
|
||||
const feedStore = useFeedStore();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const filteredFeeds = () => feedStore.getFilteredFeeds();
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
if (key.name === "escape") {
|
||||
props.onFocusChange?.(false);
|
||||
return;
|
||||
}
|
||||
const feeds = filteredFeeds();
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const feed = feeds[selectedIndex()];
|
||||
if (feed && props.onOpenFeed) {
|
||||
props.onOpenFeed(feed);
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(feeds.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 5));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
|
||||
} else if (key.name === "p") {
|
||||
// Toggle pin on selected feed
|
||||
const feed = feeds[selectedIndex()];
|
||||
if (feed) {
|
||||
feedStore.togglePinned(feed.id);
|
||||
}
|
||||
} else if (key.name === "f") {
|
||||
// Cycle visibility filter
|
||||
cycleVisibilityFilter();
|
||||
} else if (key.name === "s") {
|
||||
// Cycle sort
|
||||
cycleSortField();
|
||||
}
|
||||
|
||||
// Notify selection change
|
||||
const selectedFeed = feeds[selectedIndex()];
|
||||
if (selectedFeed && props.onSelectFeed) {
|
||||
props.onSelectFeed(selectedFeed);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return;
|
||||
handleKeyPress(key);
|
||||
});
|
||||
|
||||
const cycleVisibilityFilter = () => {
|
||||
const current = feedStore.filter().visibility;
|
||||
let next: FeedVisibility | "all";
|
||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
||||
else next = "all";
|
||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
|
||||
};
|
||||
|
||||
const cycleSortField = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
];
|
||||
const current = feedStore.filter().sortBy as FeedSortField;
|
||||
const idx = sortOptions.indexOf(current);
|
||||
const next = sortOptions[(idx + 1) % sortOptions.length];
|
||||
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
|
||||
};
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = feedStore.filter().visibility;
|
||||
if (vis === "all") return "All";
|
||||
if (vis === "public") return "Public";
|
||||
return "Private";
|
||||
};
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = feedStore.filter().sortBy;
|
||||
switch (sort) {
|
||||
case "title":
|
||||
return "Title";
|
||||
case "episodeCount":
|
||||
return "Episodes";
|
||||
case "latestEpisode":
|
||||
return "Latest";
|
||||
default:
|
||||
return "Updated";
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedClick = (feed: Feed, index: number) => {
|
||||
setSelectedIndex(index);
|
||||
if (props.onSelectFeed) {
|
||||
props.onSelectFeed(feed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedDoubleClick = (feed: Feed) => {
|
||||
if (props.onOpenFeed) {
|
||||
props.onOpenFeed(feed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with filter controls */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||
<text>
|
||||
<strong>My Feeds</strong>
|
||||
</text>
|
||||
<text fg="gray">({filteredFeeds().length} feeds)</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box border padding={0} onMouseDown={cycleVisibilityFilter}>
|
||||
<text fg="cyan">[f] {visibilityLabel()}</text>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={cycleSortField}>
|
||||
<text fg="cyan">[s] {sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Feed list in scrollbox */}
|
||||
<Show
|
||||
when={filteredFeeds().length > 0}
|
||||
fallback={
|
||||
<box border padding={2}>
|
||||
<text fg="gray">
|
||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height={15} focused={props.focused}>
|
||||
<For each={filteredFeeds()}>
|
||||
{(feed, index) => (
|
||||
<box onMouseDown={() => handleFeedClick(feed, index())}>
|
||||
<FeedItem
|
||||
feed={feed}
|
||||
isSelected={index() === selectedIndex()}
|
||||
compact={props.compact}
|
||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||
showLastUpdated={props.showLastUpdated ?? true}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
||||
{/* Navigation help */}
|
||||
<box paddingTop={0}>
|
||||
<text fg="gray">
|
||||
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
123
src/tabs/Feed/FeedPage.tsx
Normal file
123
src/tabs/Feed/FeedPage.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* FeedPage - Shows latest episodes across all subscribed shows
|
||||
* Reverse chronological order, like an inbox/timeline
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { format } from "date-fns";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
|
||||
type FeedPageProps = {
|
||||
focused: boolean;
|
||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
export function FeedPage(props: FeedPageProps) {
|
||||
const feedStore = useFeedStore();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [isRefreshing, setIsRefreshing] = createSignal(false);
|
||||
|
||||
const allEpisodes = () => feedStore.getAllEpisodesChronological();
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await feedStore.refreshAllFeeds();
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return;
|
||||
|
||||
const episodes = allEpisodes();
|
||||
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1));
|
||||
} else if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const item = episodes[selectedIndex()];
|
||||
if (item) props.onPlayEpisode?.(item.episode, item.feed);
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(episodes.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10));
|
||||
} else if (key.name === "r") {
|
||||
handleRefresh();
|
||||
} else if (key.name === "escape") {
|
||||
props.onExit?.();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" height="100%">
|
||||
{/* Status line */}
|
||||
<Show when={isRefreshing()}>
|
||||
<text fg="yellow">Refreshing feeds...</text>
|
||||
</Show>
|
||||
|
||||
{/* Episode list */}
|
||||
<Show
|
||||
when={allEpisodes().length > 0}
|
||||
fallback={
|
||||
<box padding={2}>
|
||||
<text fg="gray">
|
||||
No episodes yet. Subscribe to shows from Discover or Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height="100%" focused={props.focused}>
|
||||
<For each={allEpisodes()}>
|
||||
{(item, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={0}
|
||||
paddingBottom={0}
|
||||
backgroundColor={
|
||||
index() === selectedIndex() ? "#333" : undefined
|
||||
}
|
||||
onMouseDown={() => setSelectedIndex(index())}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||
{index() === selectedIndex() ? ">" : " "}
|
||||
</text>
|
||||
<text fg={index() === selectedIndex() ? "white" : undefined}>
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg="cyan">{item.feed.podcast.title}</text>
|
||||
<text fg="gray">{formatDate(item.episode.pubDate)}</text>
|
||||
<text fg="gray">{formatDuration(item.episode.duration)}</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user