file ordering

This commit is contained in:
2026-02-06 14:55:42 -05:00
parent 1293d30225
commit 1e3b794b8e
101 changed files with 1662 additions and 4224 deletions

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

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