mirror of
https://github.com/0x2E/fusion.git
synced 2026-05-19 18:30:35 +00:00
feat(frontend): add Google Reader-style shortcuts and help entry points
This commit is contained in:
@@ -8,12 +8,12 @@
|
||||
|
||||
## Features
|
||||
|
||||
- Feed organization with groups, unread tracking, bookmarks, and search
|
||||
- RSS/Atom parsing with feed discovery support
|
||||
- Responsive web UI with keyboard shortcuts and PWA support
|
||||
- Fast reading workflow: unread tracking, bookmarks, search, and Google Reader-style keyboard shortcuts
|
||||
- Feed management: RSS/Atom parsing, feed auto-discovery, and group organization
|
||||
- Responsive web UI with PWA support
|
||||
- Self-hosting friendly: single binary or Docker deployment
|
||||
- No AI features by design: just a focused, distraction-free RSS experience
|
||||
- Built-in i18n (English, Chinese, German, French, Spanish, Russian, Portuguese, Swedish)
|
||||
- Built-in i18n: English, Chinese, German, French, Spanish, Russian, Portuguese, Swedish
|
||||
- No AI features by design: focused, distraction-free RSS reading
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
|
||||
+18
-2
@@ -118,9 +118,25 @@ This keeps list context stable while opening/closing article detail.
|
||||
Implemented shortcuts:
|
||||
|
||||
- `Cmd/Ctrl + K`: toggle search dialog
|
||||
- `Cmd/Ctrl + ,`: open settings dialog
|
||||
- `Esc`: close search/settings/article drawer
|
||||
- `j` / `ArrowDown`: next article
|
||||
- `k` / `ArrowUp`: previous article
|
||||
- `/`: open search dialog
|
||||
- `?`: open keyboard shortcuts help
|
||||
- `j` / `n` / `ArrowDown`: next article
|
||||
- `k` / `p` / `ArrowUp`: previous article
|
||||
- `m`: toggle read/unread for current article
|
||||
- `s` / `f`: toggle star for current article
|
||||
- `o` / `v`: open current article in browser
|
||||
- `g u`: go to unread
|
||||
- `g a`: go to all
|
||||
- `g s`: go to starred
|
||||
- `g f`: go to feed management
|
||||
|
||||
Shortcut help entry points:
|
||||
|
||||
- Sidebar search button hint shows `Cmd+K / ?`
|
||||
- Search dialog Quick Actions includes a "Keyboard Shortcuts" item
|
||||
- Settings > Appearance includes a "Keyboard Shortcuts" section
|
||||
|
||||
## 10. Authentication UX
|
||||
|
||||
|
||||
@@ -67,8 +67,6 @@ export function ArticleDrawer() {
|
||||
const deleteBookmark = useDeleteBookmark();
|
||||
|
||||
const articleIds = listArticles.map((a) => a.id);
|
||||
const { goToNext, goToPrevious, hasNext, hasPrevious } =
|
||||
useArticleNavigation(articleIds);
|
||||
|
||||
const storeArticle = selectedArticleId
|
||||
? (listArticles.find((i) => i.id === selectedArticleId) ?? null)
|
||||
@@ -140,6 +138,18 @@ export function ArticleDrawer() {
|
||||
}
|
||||
};
|
||||
|
||||
const { goToNext, goToPrevious, hasNext, hasPrevious } =
|
||||
useArticleNavigation(articleIds, {
|
||||
enabled: selectedArticleId !== null,
|
||||
onToggleRead: () => {
|
||||
void handleToggleRead();
|
||||
},
|
||||
onToggleStar: () => {
|
||||
void handleToggleStar();
|
||||
},
|
||||
onOpenOriginal: handleOpenOriginal,
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={selectedArticleId !== null} onOpenChange={handleOpenChange}>
|
||||
<SheetContent
|
||||
|
||||
@@ -107,7 +107,9 @@ export function ArticleList() {
|
||||
|
||||
// Setup keyboard navigation
|
||||
const articleIds = displayArticles.map((a) => a.id);
|
||||
useArticleNavigation(articleIds);
|
||||
useArticleNavigation(articleIds, {
|
||||
enabled: selectedArticleId === null,
|
||||
});
|
||||
|
||||
// Determine title
|
||||
let title = t("article.list.all");
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AddGroupDialog } from "@/components/group/add-group-dialog";
|
||||
import { AddFeedDialog } from "@/components/feed/add-feed-dialog";
|
||||
import { EditFeedDialog } from "@/components/feed/edit-feed-dialog";
|
||||
import { ImportOpmlDialog } from "@/components/feed/import-opml-dialog";
|
||||
import { ShortcutsDialog } from "@/components/layout/shortcuts-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
@@ -63,6 +64,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
<AddFeedDialog />
|
||||
<EditFeedDialog />
|
||||
<ImportOpmlDialog />
|
||||
<ShortcutsDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
interface ShortcutItem {
|
||||
keys: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface ShortcutSection {
|
||||
title: string;
|
||||
items: ShortcutItem[];
|
||||
}
|
||||
|
||||
export function ShortcutsDialog() {
|
||||
const { t } = useI18n();
|
||||
const isShortcutsOpen = useUIStore((s) => s.isShortcutsOpen);
|
||||
const setShortcutsOpen = useUIStore((s) => s.setShortcutsOpen);
|
||||
|
||||
const sections: ShortcutSection[] = [
|
||||
{
|
||||
title: t("shortcuts.section.global"),
|
||||
items: [
|
||||
{ keys: "Cmd+K / Ctrl+K", action: t("shortcuts.action.toggleSearch") },
|
||||
{ keys: "/", action: t("shortcuts.action.openSearch") },
|
||||
{ keys: "Cmd+, / Ctrl+,", action: t("shortcuts.action.openSettings") },
|
||||
{ keys: "?", action: t("shortcuts.action.showHelp") },
|
||||
{ keys: "Esc", action: t("shortcuts.action.closeDialog") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("shortcuts.section.article"),
|
||||
items: [
|
||||
{
|
||||
keys: "j / ArrowDown / n",
|
||||
action: t("shortcuts.action.nextArticle"),
|
||||
},
|
||||
{
|
||||
keys: "k / ArrowUp / p",
|
||||
action: t("shortcuts.action.previousArticle"),
|
||||
},
|
||||
{ keys: "m", action: t("shortcuts.action.toggleRead") },
|
||||
{ keys: "s / f", action: t("shortcuts.action.toggleStar") },
|
||||
{ keys: "o / v", action: t("shortcuts.action.openOriginal") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("shortcuts.section.navigation"),
|
||||
items: [
|
||||
{ keys: "g u", action: t("shortcuts.action.goUnread") },
|
||||
{ keys: "g a", action: t("shortcuts.action.goAll") },
|
||||
{ keys: "g s", action: t("shortcuts.action.goStarred") },
|
||||
{ keys: "g f", action: t("shortcuts.action.goFeeds") },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isShortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-[560px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("shortcuts.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title} className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{section.title}</h3>
|
||||
<div className="space-y-2">
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={`${section.title}-${item.keys}`}
|
||||
className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{item.action}</span>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-mono text-xs font-medium text-foreground">
|
||||
{item.keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function Sidebar() {
|
||||
<span className="text-sm">{t("sidebar.search")}</span>
|
||||
</div>
|
||||
<kbd className="rounded bg-accent px-1.5 py-0.5 font-mono text-[11px] font-medium">
|
||||
⌘K
|
||||
Cmd+K / ?
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, Loader2, Search, Settings } from "lucide-react";
|
||||
import { FileText, Keyboard, Loader2, Search, Settings } from "lucide-react";
|
||||
import { getFaviconUrl } from "@/lib/api/favicon";
|
||||
import { searchAPI } from "@/lib/api";
|
||||
import type { SearchFeed, SearchItem } from "@/lib/api/types";
|
||||
@@ -29,7 +29,8 @@ import { FeedFavicon } from "@/components/feed/feed-favicon";
|
||||
|
||||
export function SearchDialog() {
|
||||
const { t } = useI18n();
|
||||
const { isSearchOpen, setSearchOpen, setEditFeedOpen } = useUIStore();
|
||||
const { isSearchOpen, setSearchOpen, setEditFeedOpen, setShortcutsOpen } =
|
||||
useUIStore();
|
||||
const { getFeedById } = useFeedLookup();
|
||||
const { setSelectedFeed, setSelectedArticle } = useUrlState();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -106,6 +107,11 @@ export function SearchDialog() {
|
||||
setSearchOpen(open);
|
||||
};
|
||||
|
||||
const handleOpenShortcuts = () => {
|
||||
setSearchOpen(false);
|
||||
setShortcutsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isSearchOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only">
|
||||
@@ -196,6 +202,18 @@ export function SearchDialog() {
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{t("search.quickActionHint")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
className="justify-between gap-2"
|
||||
onSelect={handleOpenShortcuts}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{t("shortcuts.title")}</span>
|
||||
</div>
|
||||
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">
|
||||
?
|
||||
</kbd>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { Bug, Download, Github, Info, Palette } from "lucide-react";
|
||||
import { Bug, Download, Github, Info, Keyboard, Palette } from "lucide-react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -50,6 +50,8 @@ function NavItem({ icon, label, active, onClick }: NavItemProps) {
|
||||
function AppearanceContent() {
|
||||
const { t } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const setSettingsOpen = useUIStore((s) => s.setSettingsOpen);
|
||||
const setShortcutsOpen = useUIStore((s) => s.setShortcutsOpen);
|
||||
const { locale, articlePageSize, setLocale, setArticlePageSize } =
|
||||
usePreferencesStore();
|
||||
|
||||
@@ -128,6 +130,27 @@ function AppearanceContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t("settings.shortcuts.label")}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{t("settings.shortcuts.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSettingsOpen(false);
|
||||
setShortcutsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Keyboard className="h-4 w-4" />
|
||||
{t("settings.shortcuts.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,241 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useUIStore } from "@/store";
|
||||
import { useUrlState } from "./use-url-state";
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tagName = target.tagName;
|
||||
if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return target.closest("[contenteditable='true'], [data-hotkey-ignore='true']") !== null;
|
||||
}
|
||||
|
||||
interface ArticleNavigationOptions {
|
||||
enabled?: boolean;
|
||||
onToggleRead?: () => void | Promise<void>;
|
||||
onToggleStar?: () => void | Promise<void>;
|
||||
onOpenOriginal?: () => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const { setSearchOpen, setSettingsOpen, isSearchOpen, isSettingsOpen } =
|
||||
useUIStore();
|
||||
const { selectedArticleId, setSelectedArticle } = useUrlState();
|
||||
const setSearchOpen = useUIStore((s) => s.setSearchOpen);
|
||||
const setSettingsOpen = useUIStore((s) => s.setSettingsOpen);
|
||||
const setShortcutsOpen = useUIStore((s) => s.setShortcutsOpen);
|
||||
const isSearchOpen = useUIStore((s) => s.isSearchOpen);
|
||||
const isSettingsOpen = useUIStore((s) => s.isSettingsOpen);
|
||||
const isShortcutsOpen = useUIStore((s) => s.isShortcutsOpen);
|
||||
const { selectedArticleId, setSelectedArticle, selectTopLevelFilter } =
|
||||
useUrlState();
|
||||
const navigate = useNavigate();
|
||||
const pendingPrefixRef = useRef<"g" | null>(null);
|
||||
const pendingPrefixTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function resetPrefix() {
|
||||
pendingPrefixRef.current = null;
|
||||
if (pendingPrefixTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingPrefixTimerRef.current);
|
||||
pendingPrefixTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startPrefix(prefix: "g") {
|
||||
resetPrefix();
|
||||
pendingPrefixRef.current = prefix;
|
||||
pendingPrefixTimerRef.current = window.setTimeout(() => {
|
||||
resetPrefix();
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// ⌘K or Ctrl+K: Open search
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
if ((event.metaKey || event.ctrlKey) && key === "k") {
|
||||
event.preventDefault();
|
||||
setSearchOpen(!isSearchOpen);
|
||||
resetPrefix();
|
||||
return;
|
||||
}
|
||||
|
||||
// ⌘, or Ctrl+, : Open settings
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === ",") {
|
||||
event.preventDefault();
|
||||
setSettingsOpen(true);
|
||||
resetPrefix();
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC: Close modals/drawer
|
||||
if (event.key === "Escape") {
|
||||
if (isSearchOpen) {
|
||||
resetPrefix();
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
if (isSettingsOpen) {
|
||||
resetPrefix();
|
||||
setSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
if (isShortcutsOpen) {
|
||||
resetPrefix();
|
||||
setShortcutsOpen(false);
|
||||
return;
|
||||
}
|
||||
if (selectedArticleId !== null) {
|
||||
resetPrefix();
|
||||
setSelectedArticle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
resetPrefix();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTypingTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSearchOpen || isSettingsOpen || isShortcutsOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingPrefixRef.current === "g") {
|
||||
if (key === "u") {
|
||||
event.preventDefault();
|
||||
selectTopLevelFilter("unread");
|
||||
resetPrefix();
|
||||
return;
|
||||
}
|
||||
if (key === "a") {
|
||||
event.preventDefault();
|
||||
selectTopLevelFilter("all");
|
||||
resetPrefix();
|
||||
return;
|
||||
}
|
||||
if (key === "s") {
|
||||
event.preventDefault();
|
||||
selectTopLevelFilter("starred");
|
||||
resetPrefix();
|
||||
return;
|
||||
}
|
||||
if (key === "f") {
|
||||
event.preventDefault();
|
||||
navigate({ to: "/feeds" });
|
||||
resetPrefix();
|
||||
return;
|
||||
}
|
||||
|
||||
resetPrefix();
|
||||
}
|
||||
|
||||
if (key === "g") {
|
||||
event.preventDefault();
|
||||
startPrefix("g");
|
||||
return;
|
||||
}
|
||||
|
||||
// /: Open search
|
||||
if (event.key === "/") {
|
||||
event.preventDefault();
|
||||
setSearchOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// ?: Open shortcuts help
|
||||
if (event.key === "?") {
|
||||
event.preventDefault();
|
||||
setShortcutsOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSearchOpen, isSettingsOpen, selectedArticleId, setSearchOpen, setSettingsOpen, setSelectedArticle]);
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
if (pendingPrefixTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingPrefixTimerRef.current);
|
||||
}
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [
|
||||
isSearchOpen,
|
||||
isSettingsOpen,
|
||||
isShortcutsOpen,
|
||||
navigate,
|
||||
selectTopLevelFilter,
|
||||
selectedArticleId,
|
||||
setSearchOpen,
|
||||
setSettingsOpen,
|
||||
setShortcutsOpen,
|
||||
setSelectedArticle,
|
||||
]);
|
||||
}
|
||||
|
||||
export function useArticleNavigation(articleIds: number[]) {
|
||||
export function useArticleNavigation(
|
||||
articleIds: number[],
|
||||
options: ArticleNavigationOptions = {},
|
||||
) {
|
||||
const { selectedArticleId, setSelectedArticle } = useUrlState();
|
||||
const isSearchOpen = useUIStore((s) => s.isSearchOpen);
|
||||
const isSettingsOpen = useUIStore((s) => s.isSettingsOpen);
|
||||
const isAddGroupOpen = useUIStore((s) => s.isAddGroupOpen);
|
||||
const isAddFeedOpen = useUIStore((s) => s.isAddFeedOpen);
|
||||
const isEditFeedOpen = useUIStore((s) => s.isEditFeedOpen);
|
||||
const isImportOpmlOpen = useUIStore((s) => s.isImportOpmlOpen);
|
||||
const isShortcutsOpen = useUIStore((s) => s.isShortcutsOpen);
|
||||
const {
|
||||
enabled = true,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onOpenOriginal,
|
||||
} = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// Don't handle if we're in an input or if no article is selected
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't handle navigation keys while typing in form fields
|
||||
if (isTypingTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
isSearchOpen ||
|
||||
isSettingsOpen ||
|
||||
isAddGroupOpen ||
|
||||
isAddFeedOpen ||
|
||||
isEditFeedOpen ||
|
||||
isImportOpmlOpen ||
|
||||
isShortcutsOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -55,8 +244,10 @@ export function useArticleNavigation(articleIds: number[]) {
|
||||
? articleIds.indexOf(selectedArticleId)
|
||||
: -1;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// J or ArrowDown: Next article
|
||||
if (event.key === "j" || event.key === "ArrowDown") {
|
||||
if (key === "j" || key === "n" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (currentIndex < articleIds.length - 1) {
|
||||
setSelectedArticle(articleIds[currentIndex + 1]);
|
||||
@@ -65,7 +256,7 @@ export function useArticleNavigation(articleIds: number[]) {
|
||||
}
|
||||
|
||||
// K or ArrowUp: Previous article
|
||||
if (event.key === "k" || event.key === "ArrowUp") {
|
||||
if (key === "k" || key === "p" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (currentIndex > 0) {
|
||||
setSelectedArticle(articleIds[currentIndex - 1]);
|
||||
@@ -73,17 +264,50 @@ export function useArticleNavigation(articleIds: number[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: Open selected article
|
||||
if (event.key === "Enter" && currentIndex >= 0) {
|
||||
if (selectedArticleId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// M: toggle read/unread
|
||||
if (key === "m" && onToggleRead) {
|
||||
event.preventDefault();
|
||||
// Article is already selected, just keeping selection
|
||||
void onToggleRead();
|
||||
return;
|
||||
}
|
||||
|
||||
// S/F: toggle star
|
||||
if ((key === "s" || key === "f") && onToggleStar) {
|
||||
event.preventDefault();
|
||||
void onToggleStar();
|
||||
return;
|
||||
}
|
||||
|
||||
// O/V: open original article
|
||||
if ((key === "o" || key === "v") && onOpenOriginal) {
|
||||
event.preventDefault();
|
||||
onOpenOriginal();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [articleIds, selectedArticleId, setSelectedArticle]);
|
||||
}, [
|
||||
articleIds,
|
||||
enabled,
|
||||
isAddFeedOpen,
|
||||
isAddGroupOpen,
|
||||
isEditFeedOpen,
|
||||
isImportOpmlOpen,
|
||||
isSearchOpen,
|
||||
isSettingsOpen,
|
||||
isShortcutsOpen,
|
||||
onOpenOriginal,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
selectedArticleId,
|
||||
setSelectedArticle,
|
||||
]);
|
||||
|
||||
const goToNext = () => {
|
||||
const currentIndex = selectedArticleId
|
||||
|
||||
@@ -30,6 +30,24 @@ export const deMessages: PartialMessages = {
|
||||
"search.quickActionHint": "Tippen, um Feeds und Artikel zu durchsuchen",
|
||||
"search.title": "Suche",
|
||||
"search.unknownFeed": "Unbekannter Feed",
|
||||
"shortcuts.action.closeDialog": "Offenen Dialog oder Drawer schliessen",
|
||||
"shortcuts.action.goAll": "Zu allen Artikeln wechseln",
|
||||
"shortcuts.action.goFeeds": "Zur Feed-Verwaltung wechseln",
|
||||
"shortcuts.action.goStarred": "Zu favorisierten Artikeln wechseln",
|
||||
"shortcuts.action.goUnread": "Zu ungelesenen Artikeln wechseln",
|
||||
"shortcuts.action.nextArticle": "Nachsten Artikel auswahlen",
|
||||
"shortcuts.action.openOriginal": "Originalartikel offnen",
|
||||
"shortcuts.action.openSearch": "Suche offnen",
|
||||
"shortcuts.action.openSettings": "Einstellungen offnen",
|
||||
"shortcuts.action.previousArticle": "Vorherigen Artikel auswahlen",
|
||||
"shortcuts.action.showHelp": "Hilfe fur Tastaturkurzel offnen",
|
||||
"shortcuts.action.toggleRead": "Gelesen/Ungelesen umschalten",
|
||||
"shortcuts.action.toggleSearch": "Suchdialog umschalten",
|
||||
"shortcuts.action.toggleStar": "Favorit umschalten",
|
||||
"shortcuts.section.article": "Artikel",
|
||||
"shortcuts.section.global": "Global",
|
||||
"shortcuts.section.navigation": "Navigation",
|
||||
"shortcuts.title": "Tastaturkurzel",
|
||||
"settings.about.description": "Ein leichter RSS-Feed-Aggregator und Reader.",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "App installieren",
|
||||
@@ -41,6 +59,9 @@ export const deMessages: PartialMessages = {
|
||||
"settings.installCancelled": "Installation abgebrochen",
|
||||
"settings.language.description": "Bevorzugte Sprache auswahlen",
|
||||
"settings.language.label": "Sprache",
|
||||
"settings.shortcuts.description": "Alle verfugbaren Tastaturkurzel anzeigen",
|
||||
"settings.shortcuts.label": "Tastaturkurzel",
|
||||
"settings.shortcuts.open": "Anzeigen",
|
||||
"settings.tab.about": "Info",
|
||||
"settings.tab.appearance": "Darstellung",
|
||||
"settings.theme.dark": "Dunkel",
|
||||
|
||||
@@ -28,6 +28,24 @@ export const enMessages = {
|
||||
"search.quickActionHint": "Type to search feeds and articles",
|
||||
"search.title": "Search",
|
||||
"search.unknownFeed": "Unknown feed",
|
||||
"shortcuts.action.closeDialog": "Close open dialog or drawer",
|
||||
"shortcuts.action.goAll": "Go to all articles",
|
||||
"shortcuts.action.goFeeds": "Go to manage feeds",
|
||||
"shortcuts.action.goStarred": "Go to starred articles",
|
||||
"shortcuts.action.goUnread": "Go to unread articles",
|
||||
"shortcuts.action.nextArticle": "Select next article",
|
||||
"shortcuts.action.openOriginal": "Open original article",
|
||||
"shortcuts.action.openSearch": "Open search",
|
||||
"shortcuts.action.openSettings": "Open settings",
|
||||
"shortcuts.action.previousArticle": "Select previous article",
|
||||
"shortcuts.action.showHelp": "Open keyboard shortcuts help",
|
||||
"shortcuts.action.toggleRead": "Toggle read/unread",
|
||||
"shortcuts.action.toggleSearch": "Toggle search dialog",
|
||||
"shortcuts.action.toggleStar": "Toggle star",
|
||||
"shortcuts.section.article": "Article",
|
||||
"shortcuts.section.global": "Global",
|
||||
"shortcuts.section.navigation": "Navigation",
|
||||
"shortcuts.title": "Keyboard Shortcuts",
|
||||
"settings.about.description": "A lightweight RSS feed aggregator and reader.",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "Install App",
|
||||
@@ -39,6 +57,9 @@ export const enMessages = {
|
||||
"settings.installCancelled": "Installation cancelled",
|
||||
"settings.language.description": "Select your preferred language",
|
||||
"settings.language.label": "Language",
|
||||
"settings.shortcuts.description": "View all available keyboard shortcuts",
|
||||
"settings.shortcuts.label": "Keyboard Shortcuts",
|
||||
"settings.shortcuts.open": "View",
|
||||
"settings.tab.about": "About",
|
||||
"settings.tab.appearance": "Appearance",
|
||||
"settings.theme.dark": "Dark",
|
||||
|
||||
@@ -30,6 +30,24 @@ export const esMessages: PartialMessages = {
|
||||
"search.quickActionHint": "Escribe para buscar feeds y articulos",
|
||||
"search.title": "Buscar",
|
||||
"search.unknownFeed": "Feed desconocido",
|
||||
"shortcuts.action.closeDialog": "Cerrar dialogo o panel abierto",
|
||||
"shortcuts.action.goAll": "Ir a todos los articulos",
|
||||
"shortcuts.action.goFeeds": "Ir a gestionar feeds",
|
||||
"shortcuts.action.goStarred": "Ir a articulos destacados",
|
||||
"shortcuts.action.goUnread": "Ir a articulos no leidos",
|
||||
"shortcuts.action.nextArticle": "Seleccionar articulo siguiente",
|
||||
"shortcuts.action.openOriginal": "Abrir articulo original",
|
||||
"shortcuts.action.openSearch": "Abrir busqueda",
|
||||
"shortcuts.action.openSettings": "Abrir ajustes",
|
||||
"shortcuts.action.previousArticle": "Seleccionar articulo anterior",
|
||||
"shortcuts.action.showHelp": "Abrir ayuda de atajos de teclado",
|
||||
"shortcuts.action.toggleRead": "Alternar leido/no leido",
|
||||
"shortcuts.action.toggleSearch": "Alternar dialogo de busqueda",
|
||||
"shortcuts.action.toggleStar": "Alternar destacado",
|
||||
"shortcuts.section.article": "Articulo",
|
||||
"shortcuts.section.global": "Global",
|
||||
"shortcuts.section.navigation": "Navegacion",
|
||||
"shortcuts.title": "Atajos de teclado",
|
||||
"settings.about.description": "Un agregador y lector RSS ligero.",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "Instalar aplicacion",
|
||||
@@ -41,6 +59,9 @@ export const esMessages: PartialMessages = {
|
||||
"settings.installCancelled": "Instalacion cancelada",
|
||||
"settings.language.description": "Selecciona tu idioma preferido",
|
||||
"settings.language.label": "Idioma",
|
||||
"settings.shortcuts.description": "Ver todos los atajos de teclado disponibles",
|
||||
"settings.shortcuts.label": "Atajos de teclado",
|
||||
"settings.shortcuts.open": "Ver",
|
||||
"settings.tab.about": "Acerca de",
|
||||
"settings.tab.appearance": "Apariencia",
|
||||
"settings.theme.dark": "Oscuro",
|
||||
|
||||
@@ -30,6 +30,24 @@ export const frMessages: PartialMessages = {
|
||||
"search.quickActionHint": "Tapez pour rechercher des flux et des articles",
|
||||
"search.title": "Recherche",
|
||||
"search.unknownFeed": "Flux inconnu",
|
||||
"shortcuts.action.closeDialog": "Fermer la boite de dialogue ou le panneau ouvert",
|
||||
"shortcuts.action.goAll": "Aller a tous les articles",
|
||||
"shortcuts.action.goFeeds": "Aller a la gestion des flux",
|
||||
"shortcuts.action.goStarred": "Aller aux articles favoris",
|
||||
"shortcuts.action.goUnread": "Aller aux articles non lus",
|
||||
"shortcuts.action.nextArticle": "Selectionner l'article suivant",
|
||||
"shortcuts.action.openOriginal": "Ouvrir l'article original",
|
||||
"shortcuts.action.openSearch": "Ouvrir la recherche",
|
||||
"shortcuts.action.openSettings": "Ouvrir les parametres",
|
||||
"shortcuts.action.previousArticle": "Selectionner l'article precedent",
|
||||
"shortcuts.action.showHelp": "Ouvrir l'aide des raccourcis clavier",
|
||||
"shortcuts.action.toggleRead": "Basculer lu/non lu",
|
||||
"shortcuts.action.toggleSearch": "Basculer la fenetre de recherche",
|
||||
"shortcuts.action.toggleStar": "Basculer favori",
|
||||
"shortcuts.section.article": "Article",
|
||||
"shortcuts.section.global": "Global",
|
||||
"shortcuts.section.navigation": "Navigation",
|
||||
"shortcuts.title": "Raccourcis clavier",
|
||||
"settings.about.description": "Un agregateur et lecteur RSS leger.",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "Installer l'application",
|
||||
@@ -41,6 +59,9 @@ export const frMessages: PartialMessages = {
|
||||
"settings.installCancelled": "Installation annulee",
|
||||
"settings.language.description": "Selectionnez votre langue preferee",
|
||||
"settings.language.label": "Langue",
|
||||
"settings.shortcuts.description": "Voir tous les raccourcis clavier disponibles",
|
||||
"settings.shortcuts.label": "Raccourcis clavier",
|
||||
"settings.shortcuts.open": "Voir",
|
||||
"settings.tab.about": "A propos",
|
||||
"settings.tab.appearance": "Apparence",
|
||||
"settings.theme.dark": "Sombre",
|
||||
|
||||
@@ -30,6 +30,24 @@ export const ptMessages: PartialMessages = {
|
||||
"search.quickActionHint": "Digite para pesquisar feeds e artigos",
|
||||
"search.title": "Pesquisar",
|
||||
"search.unknownFeed": "Feed desconhecido",
|
||||
"shortcuts.action.closeDialog": "Fechar dialogo ou painel aberto",
|
||||
"shortcuts.action.goAll": "Ir para todos os artigos",
|
||||
"shortcuts.action.goFeeds": "Ir para gerenciar feeds",
|
||||
"shortcuts.action.goStarred": "Ir para artigos com estrela",
|
||||
"shortcuts.action.goUnread": "Ir para artigos nao lidos",
|
||||
"shortcuts.action.nextArticle": "Selecionar proximo artigo",
|
||||
"shortcuts.action.openOriginal": "Abrir artigo original",
|
||||
"shortcuts.action.openSearch": "Abrir pesquisa",
|
||||
"shortcuts.action.openSettings": "Abrir configuracoes",
|
||||
"shortcuts.action.previousArticle": "Selecionar artigo anterior",
|
||||
"shortcuts.action.showHelp": "Abrir ajuda de atalhos de teclado",
|
||||
"shortcuts.action.toggleRead": "Alternar lido/nao lido",
|
||||
"shortcuts.action.toggleSearch": "Alternar dialogo de pesquisa",
|
||||
"shortcuts.action.toggleStar": "Alternar estrela",
|
||||
"shortcuts.section.article": "Artigo",
|
||||
"shortcuts.section.global": "Global",
|
||||
"shortcuts.section.navigation": "Navegacao",
|
||||
"shortcuts.title": "Atalhos de teclado",
|
||||
"settings.about.description": "Um agregador e leitor RSS leve.",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "Instalar app",
|
||||
@@ -41,6 +59,9 @@ export const ptMessages: PartialMessages = {
|
||||
"settings.installCancelled": "Instalacao cancelada",
|
||||
"settings.language.description": "Selecione seu idioma preferido",
|
||||
"settings.language.label": "Idioma",
|
||||
"settings.shortcuts.description": "Ver todos os atalhos de teclado disponiveis",
|
||||
"settings.shortcuts.label": "Atalhos de teclado",
|
||||
"settings.shortcuts.open": "Ver",
|
||||
"settings.tab.about": "Sobre",
|
||||
"settings.tab.appearance": "Aparencia",
|
||||
"settings.theme.dark": "Escuro",
|
||||
|
||||
@@ -30,6 +30,24 @@ export const ruMessages: PartialMessages = {
|
||||
"search.quickActionHint": "Введите запрос для поиска лент и статей",
|
||||
"search.title": "Поиск",
|
||||
"search.unknownFeed": "Неизвестная лента",
|
||||
"shortcuts.action.closeDialog": "Закрыть открытый диалог или панель",
|
||||
"shortcuts.action.goAll": "Перейти ко всем статьям",
|
||||
"shortcuts.action.goFeeds": "Перейти к управлению лентами",
|
||||
"shortcuts.action.goStarred": "Перейти к избранным статьям",
|
||||
"shortcuts.action.goUnread": "Перейти к непрочитанным статьям",
|
||||
"shortcuts.action.nextArticle": "Выбрать следующую статью",
|
||||
"shortcuts.action.openOriginal": "Открыть оригинальную статью",
|
||||
"shortcuts.action.openSearch": "Открыть поиск",
|
||||
"shortcuts.action.openSettings": "Открыть настройки",
|
||||
"shortcuts.action.previousArticle": "Выбрать предыдущую статью",
|
||||
"shortcuts.action.showHelp": "Открыть справку по горячим клавишам",
|
||||
"shortcuts.action.toggleRead": "Переключить прочитано/непрочитано",
|
||||
"shortcuts.action.toggleSearch": "Переключить окно поиска",
|
||||
"shortcuts.action.toggleStar": "Переключить избранное",
|
||||
"shortcuts.section.article": "Статья",
|
||||
"shortcuts.section.global": "Глобальные",
|
||||
"shortcuts.section.navigation": "Навигация",
|
||||
"shortcuts.title": "Горячие клавиши",
|
||||
"settings.about.description": "Легкий RSS-агрегатор и ридер.",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "Установить приложение",
|
||||
@@ -41,6 +59,9 @@ export const ruMessages: PartialMessages = {
|
||||
"settings.installCancelled": "Установка отменена",
|
||||
"settings.language.description": "Выберите предпочтительный язык",
|
||||
"settings.language.label": "Язык",
|
||||
"settings.shortcuts.description": "Показать все доступные горячие клавиши",
|
||||
"settings.shortcuts.label": "Горячие клавиши",
|
||||
"settings.shortcuts.open": "Показать",
|
||||
"settings.tab.about": "О приложении",
|
||||
"settings.tab.appearance": "Внешний вид",
|
||||
"settings.theme.dark": "Темная",
|
||||
|
||||
@@ -30,6 +30,24 @@ export const svMessages: PartialMessages = {
|
||||
"search.quickActionHint": "Skriv for att soka i floden och artiklar",
|
||||
"search.title": "Sok",
|
||||
"search.unknownFeed": "Okant flode",
|
||||
"shortcuts.action.closeDialog": "Stang oppen dialog eller panel",
|
||||
"shortcuts.action.goAll": "Ga till alla artiklar",
|
||||
"shortcuts.action.goFeeds": "Ga till hantera floden",
|
||||
"shortcuts.action.goStarred": "Ga till stjarnmarkta artiklar",
|
||||
"shortcuts.action.goUnread": "Ga till olasta artiklar",
|
||||
"shortcuts.action.nextArticle": "Valj nasta artikel",
|
||||
"shortcuts.action.openOriginal": "Oppna originalartikel",
|
||||
"shortcuts.action.openSearch": "Oppna sokning",
|
||||
"shortcuts.action.openSettings": "Oppna installningar",
|
||||
"shortcuts.action.previousArticle": "Valj forra artikel",
|
||||
"shortcuts.action.showHelp": "Oppna hjalp for kortkommandon",
|
||||
"shortcuts.action.toggleRead": "Vaxla last/olast",
|
||||
"shortcuts.action.toggleSearch": "Vaxla sokdialog",
|
||||
"shortcuts.action.toggleStar": "Vaxla stjarnmarkering",
|
||||
"shortcuts.section.article": "Artikel",
|
||||
"shortcuts.section.global": "Global",
|
||||
"shortcuts.section.navigation": "Navigering",
|
||||
"shortcuts.title": "Kortkommandon",
|
||||
"settings.about.description": "En lattviktig RSS-aggregator och lasare.",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "Installera app",
|
||||
@@ -41,6 +59,9 @@ export const svMessages: PartialMessages = {
|
||||
"settings.installCancelled": "Installationen avbrots",
|
||||
"settings.language.description": "Valj ditt foredragna sprak",
|
||||
"settings.language.label": "Sprak",
|
||||
"settings.shortcuts.description": "Visa alla tillgangliga kortkommandon",
|
||||
"settings.shortcuts.label": "Kortkommandon",
|
||||
"settings.shortcuts.open": "Visa",
|
||||
"settings.tab.about": "Om",
|
||||
"settings.tab.appearance": "Utseende",
|
||||
"settings.theme.dark": "Mork",
|
||||
|
||||
@@ -30,6 +30,24 @@ export const zhMessages: PartialMessages = {
|
||||
"search.quickActionHint": "输入关键词以搜索订阅源和文章",
|
||||
"search.title": "搜索",
|
||||
"search.unknownFeed": "未知订阅源",
|
||||
"shortcuts.action.closeDialog": "关闭当前打开的弹窗或抽屉",
|
||||
"shortcuts.action.goAll": "跳转到全部文章",
|
||||
"shortcuts.action.goFeeds": "跳转到订阅管理",
|
||||
"shortcuts.action.goStarred": "跳转到收藏文章",
|
||||
"shortcuts.action.goUnread": "跳转到未读文章",
|
||||
"shortcuts.action.nextArticle": "选择下一篇文章",
|
||||
"shortcuts.action.openOriginal": "打开原文",
|
||||
"shortcuts.action.openSearch": "打开搜索",
|
||||
"shortcuts.action.openSettings": "打开设置",
|
||||
"shortcuts.action.previousArticle": "选择上一篇文章",
|
||||
"shortcuts.action.showHelp": "打开快捷键帮助",
|
||||
"shortcuts.action.toggleRead": "切换已读/未读",
|
||||
"shortcuts.action.toggleSearch": "切换搜索弹窗",
|
||||
"shortcuts.action.toggleStar": "切换收藏",
|
||||
"shortcuts.section.article": "文章",
|
||||
"shortcuts.section.global": "全局",
|
||||
"shortcuts.section.navigation": "导航",
|
||||
"shortcuts.title": "键盘快捷键",
|
||||
"settings.about.description": "一个轻量级 RSS 聚合与阅读器。",
|
||||
"settings.about.github": "GitHub",
|
||||
"settings.about.install": "安装应用",
|
||||
@@ -41,6 +59,9 @@ export const zhMessages: PartialMessages = {
|
||||
"settings.installCancelled": "已取消安装",
|
||||
"settings.language.description": "选择你偏好的语言",
|
||||
"settings.language.label": "语言",
|
||||
"settings.shortcuts.description": "查看所有可用的键盘快捷键",
|
||||
"settings.shortcuts.label": "键盘快捷键",
|
||||
"settings.shortcuts.open": "查看",
|
||||
"settings.tab.about": "关于",
|
||||
"settings.tab.appearance": "外观",
|
||||
"settings.theme.dark": "深色",
|
||||
|
||||
@@ -10,6 +10,7 @@ interface UIState {
|
||||
isEditFeedOpen: boolean;
|
||||
editingFeed: Feed | null;
|
||||
isImportOpmlOpen: boolean;
|
||||
isShortcutsOpen: boolean;
|
||||
|
||||
// Mobile sidebar
|
||||
isSidebarOpen: boolean;
|
||||
@@ -21,6 +22,7 @@ interface UIState {
|
||||
setAddFeedOpen: (open: boolean) => void;
|
||||
setEditFeedOpen: (open: boolean, feed?: Feed) => void;
|
||||
setImportOpmlOpen: (open: boolean) => void;
|
||||
setShortcutsOpen: (open: boolean) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
isEditFeedOpen: false,
|
||||
editingFeed: null,
|
||||
isImportOpmlOpen: false,
|
||||
isShortcutsOpen: false,
|
||||
isSidebarOpen: false,
|
||||
|
||||
setSearchOpen: (open) => set({ isSearchOpen: open }),
|
||||
@@ -47,5 +50,7 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
|
||||
setImportOpmlOpen: (open) => set({ isImportOpmlOpen: open }),
|
||||
|
||||
setShortcutsOpen: (open) => set({ isShortcutsOpen: open }),
|
||||
|
||||
setSidebarOpen: (open) => set({ isSidebarOpen: open }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user