feat: private feeds, all input fields supersede keyboard nav
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, Show, For } from "solid-js";
|
import { createSignal, Show, For } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { useKeyboard, useRenderer } from "@opentui/solid";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||||
@@ -43,6 +43,7 @@ export function Shell() {
|
|||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const k = useKeybinds();
|
const k = useKeybinds();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
|
const renderer = useRenderer();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
@@ -205,6 +206,11 @@ export function Shell() {
|
|||||||
if (evt.name === "escape") {
|
if (evt.name === "escape") {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
nav.setInputFocused(false);
|
nav.setInputFocused(false);
|
||||||
|
// Actually blur the focused renderable too — setting the flag alone
|
||||||
|
// leaves the opentui input owning keys, so nav keys would still be
|
||||||
|
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
|
||||||
|
// (and re-blurs the SearchPage input via its `focused` prop).
|
||||||
|
renderer.currentFocusedRenderable?.blur();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/hooks/useInputFocusNav.ts
Normal file
65
src/hooks/useInputFocusNav.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* useInputFocusNav — returns a `ref` callback for an `<input>` (or any
|
||||||
|
* focusable renderable) that holds the navigation store's `inputFocused`
|
||||||
|
* flag true while the renderable has focus.
|
||||||
|
*
|
||||||
|
* Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
|
||||||
|
* whatever is focused only when `nav.inputFocused()` is true; otherwise it
|
||||||
|
* dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
|
||||||
|
* depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
|
||||||
|
* flag, so typing into them *also* fired the navigation keybinds. Wiring the
|
||||||
|
* flag to each input's real focus/blur state fixes that.
|
||||||
|
*
|
||||||
|
* A module-level counter guards the blur→focus ordering gap that occurs when
|
||||||
|
* tabbing between two inputs in the same form (the old input blurs before the
|
||||||
|
* new one focuses) so the flag never flickers off mid-handoff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { onCleanup } from "solid-js";
|
||||||
|
import { RenderableEvents } from "@opentui/core";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
|
||||||
|
// Inputs (managed by this hook) currently holding focus.
|
||||||
|
let focusedCount = 0;
|
||||||
|
|
||||||
|
export function useInputFocusNav() {
|
||||||
|
const nav = useNavigation();
|
||||||
|
let current: any | undefined;
|
||||||
|
|
||||||
|
const onFocused = () => {
|
||||||
|
focusedCount++;
|
||||||
|
nav.setInputFocused(true);
|
||||||
|
};
|
||||||
|
const onBlurred = () => {
|
||||||
|
focusedCount = Math.max(0, focusedCount - 1);
|
||||||
|
if (focusedCount === 0) nav.setInputFocused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const detach = (el: any) => {
|
||||||
|
el.off(RenderableEvents.FOCUSED, onFocused);
|
||||||
|
el.off(RenderableEvents.BLURRED, onBlurred);
|
||||||
|
// Treat a focused element being torn down as a blur so the counter
|
||||||
|
// doesn't leak and leave inputFocused stuck on.
|
||||||
|
if (el.focused) onBlurred();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ref = (el: any) => {
|
||||||
|
if (current && current !== el) detach(current);
|
||||||
|
current = el;
|
||||||
|
if (el) {
|
||||||
|
el.on(RenderableEvents.FOCUSED, onFocused);
|
||||||
|
el.on(RenderableEvents.BLURRED, onBlurred);
|
||||||
|
// If the renderable is already focused when attached, count it.
|
||||||
|
if (el.focused) onFocused();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (current) {
|
||||||
|
detach(current);
|
||||||
|
current = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -1,38 +1,55 @@
|
|||||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
let current = value
|
let current = value;
|
||||||
return [() => current, (next) => {
|
return [
|
||||||
current = next
|
() => current,
|
||||||
}]
|
(next) => {
|
||||||
}
|
current = next;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
import { SyncStatus } from "./SyncStatus"
|
import { SyncStatus } from "./SyncStatus";
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
export function ExportDialog() {
|
export function ExportDialog() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const filename = createSignal("podcast-sync.json")
|
const filename = createSignal("podcast-sync.json");
|
||||||
const format = createSignal<"json" | "xml">("json")
|
const format = createSignal<"json" | "xml">("json");
|
||||||
|
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||||
|
const filenameRef = useInputFocusNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
<box
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
border
|
||||||
<text fg={theme.text}>File:</text>
|
title="Export"
|
||||||
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
style={{ padding: 1, flexDirection: "column", gap: 1 }}
|
||||||
</box>
|
>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<text fg={theme.text}>Format:</text>
|
<text fg={theme.text}>File:</text>
|
||||||
<tab_select
|
<input
|
||||||
options={[
|
ref={filenameRef}
|
||||||
{ name: "JSON", description: "Portable" },
|
value={filename[0]()}
|
||||||
{ name: "XML", description: "Structured" },
|
onInput={filename[1]}
|
||||||
]}
|
style={{ width: 30 }}
|
||||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
/>
|
||||||
/>
|
</box>
|
||||||
</box>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<box border borderColor={theme.border}>
|
<text fg={theme.text}>Format:</text>
|
||||||
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text>
|
<tab_select
|
||||||
</box>
|
options={[
|
||||||
<SyncStatus />
|
{ name: "JSON", description: "Portable" },
|
||||||
</box>
|
{ name: "XML", description: "Structured" },
|
||||||
)
|
]}
|
||||||
|
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<box border borderColor={theme.border}>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
Export {format[0]()} to {filename[0]()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<SyncStatus />
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { detectFormat } from "@/utils/file-detector";
|
import { detectFormat } from "@/utils/file-detector";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
type FilePickerProps = {
|
type FilePickerProps = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilePicker(props: FilePickerProps) {
|
export function FilePicker(props: FilePickerProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const format = detectFormat(props.value);
|
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||||
|
const inputRef = useInputFocusNav();
|
||||||
|
const format = detectFormat(props.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||||
<input
|
<input
|
||||||
value={props.value}
|
ref={inputRef}
|
||||||
onInput={props.onChange}
|
value={props.value}
|
||||||
placeholder="/path/to/sync-file.json"
|
onInput={props.onChange}
|
||||||
style={{ width: 40 }}
|
placeholder="/path/to/sync-file.json"
|
||||||
/>
|
style={{ width: 40 }}
|
||||||
<text fg={theme.text}>Format: {format}</text>
|
/>
|
||||||
</box>
|
<text fg={theme.text}>Format: {format}</text>
|
||||||
);
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { createSignal, For, Show } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
import { SourceType } from "@/types/source";
|
import { SourceType } from "@/types/source";
|
||||||
import type { PodcastSource } from "@/types/source";
|
import type { PodcastSource } from "@/types/source";
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
@@ -61,6 +62,9 @@ function AddSourceForm() {
|
|||||||
const [name, setName] = createSignal("");
|
const [name, setName] = createSignal("");
|
||||||
const [url, setUrl] = createSignal("");
|
const [url, setUrl] = createSignal("");
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
// Yield navigation keybinds to the Shell router while either input is focused.
|
||||||
|
const nameRef = useInputFocusNav();
|
||||||
|
const urlRef = useInputFocusNav();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const u = url().trim();
|
const u = url().trim();
|
||||||
@@ -94,6 +98,7 @@ function AddSourceForm() {
|
|||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>Name:</text>
|
<text fg={theme.textMuted}>Name:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={nameRef}
|
||||||
value={name()}
|
value={name()}
|
||||||
onInput={setName}
|
onInput={setName}
|
||||||
placeholder="My Custom Feed"
|
placeholder="My Custom Feed"
|
||||||
@@ -103,6 +108,7 @@ function AddSourceForm() {
|
|||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>URL:</text>
|
<text fg={theme.textMuted}>URL:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={urlRef}
|
||||||
value={url()}
|
value={url()}
|
||||||
onInput={(v) => {
|
onInput={(v) => {
|
||||||
setUrl(v);
|
setUrl(v);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { searchPodcasts } from "../utils/search";
|
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult } from "../types/source";
|
import type { SearchResult } from "../types/source";
|
||||||
|
|
||||||
@@ -83,6 +83,15 @@ export function createSearchStore() {
|
|||||||
addToHistory(q);
|
addToHistory(q);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// A query that is a direct RSS feed URL (e.g. a private feed that
|
||||||
|
// isn't in any public directory) resolves to that feed directly,
|
||||||
|
// independent of enabled search sources.
|
||||||
|
const urlResults = await searchByFeedUrl(q);
|
||||||
|
if (urlResults.length > 0) {
|
||||||
|
setResults(applySubscribedStatus(urlResults));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sources = feedStore.sources();
|
const sources = feedStore.sources();
|
||||||
const enabledSourceIds = sources
|
const enabledSourceIds = sources
|
||||||
.filter((s) => s.enabled)
|
.filter((s) => s.enabled)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { searchSourceByType } from "./source-searcher";
|
import { searchSourceByType } from "./source-searcher";
|
||||||
|
import { parseRSSFeed } from "../api/rss-parser";
|
||||||
|
import { SourceType } from "../types/source";
|
||||||
import type { PodcastSource, SearchResult } from "../types/source";
|
import type { PodcastSource, SearchResult } from "../types/source";
|
||||||
|
|
||||||
type SearchCacheEntry = {
|
type SearchCacheEntry = {
|
||||||
@@ -55,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FEED_URL_RE = /^https?:\/\/.+/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the query is a direct RSS feed URL (useful for private feeds that aren't
|
||||||
|
* in public directories), fetch and parse it into a single search result.
|
||||||
|
* Returns an empty array when the query is not a URL so normal search proceeds.
|
||||||
|
*/
|
||||||
|
export const searchByFeedUrl = async (
|
||||||
|
query: string,
|
||||||
|
): Promise<SearchResult[]> => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!FEED_URL_RE.test(trimmed)) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(trimmed, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const podcast = parseRSSFeed(xml, trimmed);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sourceId: "direct-rss",
|
||||||
|
sourceName: "RSS Feed",
|
||||||
|
sourceType: SourceType.RSS,
|
||||||
|
// parseRSSFeed marks feeds subscribed; a search result should start
|
||||||
|
// unsubscribed so the store can flag it correctly if already added.
|
||||||
|
podcast: { ...podcast, isSubscribed: false },
|
||||||
|
score: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const searchPodcasts = async (
|
export const searchPodcasts = async (
|
||||||
query: string,
|
query: string,
|
||||||
sourceIds: string[],
|
sourceIds: string[],
|
||||||
|
|||||||
Reference in New Issue
Block a user