Saved feed rows: inline edit panel for label + query

- Add updateSavedFeed to useSavedFeeds (updates label and/or filters atomically)
- SavedFeedRow now has a collapsible edit panel below the summary row:
  two inputs side-by-side (Tab name / Search query), Cancel + Save buttons
- Pencil icon toggles the panel open/closed; X in the panel header closes it
- Enter in the query field saves, Escape cancels
- The summary row (label + filter chips) always stays visible above the panel
- Remove button stays in the header row, hidden until hover
This commit is contained in:
Chad Curtis
2026-03-05 21:21:36 -06:00
parent 04c069dab1
commit 8aac7c2a27
2 changed files with 118 additions and 89 deletions
+108 -89
View File
@@ -566,28 +566,24 @@ function FeedTabsSection() {
// ─── Saved Feeds Section ─────────────────────────────────────────────────────
function SavedFeedsSection() {
const { savedFeeds, removeSavedFeed, renameSavedFeed, isPending } = useSavedFeeds();
const { savedFeeds, removeSavedFeed, updateSavedFeed, isPending } = useSavedFeeds();
const { toast } = useToast();
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const feedFeeds = savedFeeds.filter((f) => f.destination === 'feed');
const profileFeeds = savedFeeds.filter((f) => f.destination === 'profile');
if (savedFeeds.length === 0) return null;
const startRename = (feed: SavedFeed) => {
setRenamingId(feed.id);
setRenameValue(feed.label);
};
const commitRename = async (id: string) => {
const trimmed = renameValue.trim();
if (trimmed) {
await renameSavedFeed(id, trimmed);
toast({ title: 'Feed renamed' });
}
setRenamingId(null);
const handleSave = async (id: string, label: string, query: string) => {
const feed = savedFeeds.find((f) => f.id === id);
if (!feed) return;
await updateSavedFeed(id, {
label,
filters: { ...feed.filters, query },
});
toast({ title: 'Feed updated' });
setEditingId(null);
};
const handleRemove = async (feed: SavedFeed) => {
@@ -597,13 +593,11 @@ function SavedFeedsSection() {
return (
<div className="px-3 py-4 space-y-4 border-t border-border">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold flex-1">Saved Feeds</h3>
</div>
<h3 className="text-sm font-semibold">Saved Feeds</h3>
{feedFeeds.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Home className="size-3.5" />
Home feed tabs
</p>
@@ -611,12 +605,10 @@ function SavedFeedsSection() {
<SavedFeedRow
key={feed.id}
feed={feed}
isRenaming={renamingId === feed.id}
renameValue={renameValue}
onRenameValueChange={setRenameValue}
onStartRename={() => startRename(feed)}
onCommitRename={() => commitRename(feed.id)}
onCancelRename={() => setRenamingId(null)}
isEditing={editingId === feed.id}
onEdit={() => setEditingId(feed.id)}
onCancel={() => setEditingId(null)}
onSave={(label, query) => handleSave(feed.id, label, query)}
onRemove={() => handleRemove(feed)}
isPending={isPending}
/>
@@ -625,8 +617,8 @@ function SavedFeedsSection() {
)}
{profileFeeds.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<User className="size-3.5" />
Profile tabs
</p>
@@ -634,12 +626,10 @@ function SavedFeedsSection() {
<SavedFeedRow
key={feed.id}
feed={feed}
isRenaming={renamingId === feed.id}
renameValue={renameValue}
onRenameValueChange={setRenameValue}
onStartRename={() => startRename(feed)}
onCommitRename={() => commitRename(feed.id)}
onCancelRename={() => setRenamingId(null)}
isEditing={editingId === feed.id}
onEdit={() => setEditingId(feed.id)}
onCancel={() => setEditingId(null)}
onSave={(label, query) => handleSave(feed.id, label, query)}
onRemove={() => handleRemove(feed)}
isPending={isPending}
/>
@@ -652,25 +642,31 @@ function SavedFeedsSection() {
function SavedFeedRow({
feed,
isRenaming,
renameValue,
onRenameValueChange,
onStartRename,
onCommitRename,
onCancelRename,
isEditing,
onEdit,
onCancel,
onSave,
onRemove,
isPending,
}: {
feed: SavedFeed;
isRenaming: boolean;
renameValue: string;
onRenameValueChange: (v: string) => void;
onStartRename: () => void;
onCommitRename: () => void;
onCancelRename: () => void;
isEditing: boolean;
onEdit: () => void;
onCancel: () => void;
onSave: (label: string, query: string) => void;
onRemove: () => void;
isPending: boolean;
}) {
const [labelValue, setLabelValue] = useState(feed.label);
const [queryValue, setQueryValue] = useState(feed.filters.query);
// Reset local state when this row starts editing
const handleEdit = () => {
setLabelValue(feed.label);
setQueryValue(feed.filters.query);
onEdit();
};
const SortIcon = feed.filters.sort === 'hot' ? Flame : feed.filters.sort === 'trending' ? TrendingUp : Clock;
const scopeLabel = feed.filters.authorScope === 'follows'
? 'Follows'
@@ -679,21 +675,14 @@ function SavedFeedRow({
: null;
return (
<div className="flex items-center gap-2 py-2 px-2.5 rounded-lg bg-secondary/30 border border-border/50 group">
{isRenaming ? (
<Input
value={renameValue}
onChange={(e) => onRenameValueChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onCommitRename(); if (e.key === 'Escape') onCancelRename(); }}
className="h-7 text-sm flex-1 bg-background"
autoFocus
/>
) : (
<div className={cn('rounded-lg border bg-secondary/30 overflow-hidden transition-colors', isEditing ? 'border-border' : 'border-border/50 group')}>
{/* Summary row — always visible */}
<div className="flex items-center gap-2 py-2 px-2.5">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{feed.label}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{feed.filters.query && (
<span className="text-[10px] text-muted-foreground bg-secondary rounded px-1 py-0.5 truncate max-w-[120px]">"{feed.filters.query}"</span>
<span className="text-[10px] text-muted-foreground bg-secondary rounded px-1 py-0.5 truncate max-w-[140px]">"{feed.filters.query}"</span>
)}
{scopeLabel && (
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
@@ -707,37 +696,22 @@ function SavedFeedRow({
)}
</div>
</div>
)}
{/* Actions */}
<div className={cn('flex items-center gap-0.5 shrink-0', !isRenaming && 'opacity-0 group-hover:opacity-100 transition-opacity')}>
{isRenaming ? (
<>
<button
onClick={onCommitRename}
disabled={isPending || !renameValue.trim()}
className="size-7 flex items-center justify-center rounded text-primary hover:bg-primary/10 disabled:opacity-40 transition-colors"
aria-label="Save name"
>
{isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
</button>
<button
onClick={onCancelRename}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:bg-secondary transition-colors"
aria-label="Cancel"
>
<X className="size-3.5" />
</button>
</>
) : (
<>
<button
onClick={onStartRename}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
aria-label="Rename"
>
<Pencil className="size-3.5" />
</button>
{/* Actions */}
<div className={cn('flex items-center gap-0.5 shrink-0', !isEditing && 'opacity-0 group-hover:opacity-100 transition-opacity')}>
<button
onClick={isEditing ? onCancel : handleEdit}
className={cn(
'size-7 flex items-center justify-center rounded transition-colors',
isEditing
? 'text-muted-foreground hover:bg-secondary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
)}
aria-label={isEditing ? 'Cancel edit' : 'Edit'}
>
{isEditing ? <X className="size-3.5" /> : <Pencil className="size-3.5" />}
</button>
{!isEditing && (
<button
onClick={onRemove}
disabled={isPending}
@@ -746,9 +720,54 @@ function SavedFeedRow({
>
<X className="size-3.5" />
</button>
</>
)}
)}
</div>
</div>
{/* Inline edit panel */}
{isEditing && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-border/50 space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Tab name</label>
<Input
value={labelValue}
onChange={(e) => setLabelValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
className="h-7 text-sm bg-background"
placeholder="Tab name…"
autoFocus
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Search query</label>
<Input
value={queryValue}
onChange={(e) => setQueryValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onSave(labelValue, queryValue); if (e.key === 'Escape') onCancel(); }}
className="h-7 text-sm bg-background"
placeholder="e.g. bitcoin"
/>
</div>
</div>
<div className="flex justify-end gap-1.5">
<button
onClick={onCancel}
className="h-7 px-2.5 rounded text-xs text-muted-foreground hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
onClick={() => onSave(labelValue, queryValue)}
disabled={isPending || !labelValue.trim()}
className="h-7 px-2.5 rounded text-xs bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors flex items-center gap-1"
>
{isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
Save
</button>
</div>
</div>
)}
</div>
);
}
+10
View File
@@ -50,12 +50,22 @@ export function useSavedFeeds() {
await updateSettings.mutateAsync({ savedFeeds: updated });
};
/** Update a saved feed's label and/or filters. */
const updateSavedFeed = async (id: string, changes: Partial<Pick<SavedFeed, 'label' | 'filters'>>): Promise<void> => {
if (!user) throw new Error('Must be logged in to update feeds');
const updated = savedFeeds.map((f) =>
f.id === id ? { ...f, ...changes, label: (changes.label ?? f.label).trim() } : f,
);
await updateSettings.mutateAsync({ savedFeeds: updated });
};
return {
savedFeeds,
isLoading,
addSavedFeed,
removeSavedFeed,
renameSavedFeed,
updateSavedFeed,
isPending: updateSettings.isPending,
};
}