Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions smnb/components/livefeed/StoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ interface StoryCardProps {
className?: string;
theme?: keyof typeof StoryThemes;
showActions?: boolean;
onAction?: (action: 'read' | 'share' | 'bookmark', story: CompletedStory) => void;
onAction?: (action: 'read' | 'share' | 'bookmark' | 'pin' | 'unpin' | 'remove', story: CompletedStory) => void;
}

export default function StoryCard({
story,
isFirst = false,
className,
theme = isFirst ? 'highlighted' : 'default',
theme = story.isPinned ? 'pinned' : (isFirst ? 'highlighted' : 'default'),
showActions = false,
onAction
}: StoryCardProps) {
Expand Down Expand Up @@ -72,17 +72,66 @@ export default function StoryCard({
{story.tone}
</span>

{/* Pinned indicator */}
{story.isPinned && (
<span className={StoryCardTokens.pinnedBadge}>
PINNED #{story.pinnedOrder}
</span>
)}

{/* Thread indicators */}
{story.isThreadUpdate && (
<span className={StoryCardTokens.threadUpdate}>
UPDATE
</span>
)}

{/* Action buttons - Pin and Remove (on hover) */}
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onAction && (
<>
{/* Pin/Unpin Button */}
<button
onClick={(e) => {
e.stopPropagation();
onAction(story.isPinned ? 'unpin' : 'pin', story);
}}
className={`p-1 rounded transition-colors cursor-pointer ${
story.isPinned
? 'text-yellow-400 hover:text-yellow-300'
: 'text-muted-foreground hover:text-foreground'
}`}
title={story.isPinned ? 'Unpin story' : 'Pin story to top'}
aria-label={story.isPinned ? 'Unpin story' : 'Pin story to top'}
>
{React.createElement(StoryDisplayUtils.getPinIcon(), {
size: 14,
className: story.isPinned ? 'fill-current' : ''
})}
</button>

{/* Remove Button */}
<button
onClick={(e) => {
e.stopPropagation();
onAction('remove', story);
}}
className="p-1 rounded transition-colors text-muted-foreground hover:text-red-400 cursor-pointer"
title="Remove story"
aria-label="Remove story"
>
{React.createElement(StoryDisplayUtils.getCloseIcon(), {
size: 14
})}
</button>
</>
)}
</div>

{/* Source attribution */}
{story.originalItem?.subreddit && (
<span
className="ml-auto flex items-center cursor-pointer p-1 rounded transition-colors"
className="flex items-center cursor-pointer p-1 rounded transition-colors"
title={`Source: r/${story.originalItem.subreddit}`}
aria-label={`Source: r/${story.originalItem.subreddit}`}
onClick={() => setIsBookmarked(!isBookmarked)}
Expand Down
59 changes: 55 additions & 4 deletions smnb/components/livefeed/liveFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { useEffect, useState } from 'react';
import React from 'react';
import { useSimpleLiveFeedStore } from '@/lib/stores/livefeed/simpleLiveFeedStore';
import { useSimpleLiveFeedStore, CompletedStory } from '@/lib/stores/livefeed/simpleLiveFeedStore';
import { Trash2 } from 'lucide-react';
import StoryCard from './StoryCard';

Expand All @@ -20,8 +20,50 @@ export default function LiveFeed({ className }: LiveFeedProps) {
storyHistory,
clearStoryHistory,
loadStoriesFromConvex,
pinStory,
unpinStory,
removeStory,
addMultipleTestStories,
} = useSimpleLiveFeedStore();

// Sort stories with pinned ones first
const sortedStories = React.useMemo(() => {
return [...storyHistory].sort((a, b) => {
// Pinned stories first
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;

// If both pinned, sort by pinnedOrder
if (a.isPinned && b.isPinned) {
return (a.pinnedOrder || 0) - (b.pinnedOrder || 0);
}

// For unpinned stories, maintain original order (newest first)
return b.timestamp.getTime() - a.timestamp.getTime();
});
}, [storyHistory]);

// Handle story actions
const handleStoryAction = (action: 'read' | 'share' | 'bookmark' | 'pin' | 'unpin' | 'remove', story: CompletedStory) => {
switch (action) {
case 'pin':
pinStory(story.id);
break;
case 'unpin':
unpinStory(story.id);
break;
case 'remove':
removeStory(story.id);
break;
case 'read':
case 'share':
case 'bookmark':
// These actions can be handled later if needed
console.log(`${action} action for story:`, story.id);
break;
}
};

// Check for reduced motion preference
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
Expand All @@ -44,9 +86,17 @@ export default function LiveFeed({ className }: LiveFeedProps) {
{/* Fixed Header */}
<div className="flex-shrink-0 bg-[#191919] backdrop-blur-sm border-b border-border/20 flex items-center justify-between px-4 py-2">
<div className="text-sm font-light text-muted-foreground font-sans">
Live Stories {storyHistory.length > 0 ? `(${storyHistory.length})` : ''}
Live Stories {sortedStories.length > 0 ? `(${sortedStories.length})` : ''}
</div>
<div className="flex items-center gap-2">
{/* Add test stories button for demonstration */}
<button
onClick={addMultipleTestStories}
title="Add Test Stories"
className="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-500 text-white rounded transition-colors cursor-pointer"
>
Add Test Stories
</button>
<button
onClick={clearStoryHistory}
title="Clear Stories"
Expand All @@ -69,12 +119,13 @@ export default function LiveFeed({ className }: LiveFeedProps) {

<div className="space-y-4 px-2 pt-2 relative z-10">
<div className="space-y-3">
{storyHistory.map((story, index) => (
{sortedStories.map((story, index) => (
<StoryCard
key={story.id}
story={story}
isFirst={false}
showActions={false}
showActions={true}
onAction={handleStoryAction}
className={reducedMotion ? '' : 'animate-slide-in-top'}
/>
))}
Expand Down
145 changes: 145 additions & 0 deletions smnb/lib/stores/livefeed/simpleLiveFeedStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export interface CompletedStory {
updateType?: StoryUpdate['updateType']; // Type of update if it's an update
threadTopic?: string; // Topic of the thread for display
updateCount?: number; // Number of updates in the thread

// Pin functionality
isPinned?: boolean; // Whether this story is pinned
pinnedOrder?: number; // Order of pinned stories (1 = top, 2 = second, etc.)
}

interface SimpleLiveFeedStore {
Expand Down Expand Up @@ -147,6 +151,12 @@ interface SimpleLiveFeedStore {
addCompletedStory: (story: CompletedStory) => void;
clearStoryHistory: () => void;
addTestStory: () => void; // For testing
addMultipleTestStories: () => void; // For pin testing

// Pin actions
pinStory: (storyId: string) => void;
unpinStory: (storyId: string) => void;
removeStory: (storyId: string) => void;

// Convex integration
loadStoriesFromConvex: () => Promise<void>;
Expand Down Expand Up @@ -658,6 +668,64 @@ export const useSimpleLiveFeedStore = create<SimpleLiveFeedStore>((set, get) =>
});
},

// Add multiple test stories for pin functionality testing
addMultipleTestStories: () => {
const testStories = [
{
id: `test-story-1-${Date.now()}`,
narrative: "First test story about AI developments in the tech industry. This story will help us test the pin functionality and ensure everything works as expected.",
tone: 'breaking' as const,
priority: 'high' as const,
timestamp: new Date(Date.now() - 1000),
duration: 30,
originalItem: {
title: "First AI Story",
author: "tech_reporter1",
subreddit: "technology"
},
sentiment: 'positive' as const,
topics: ['AI', 'Technology']
},
{
id: `test-story-2-${Date.now()}`,
narrative: "Second test story covering developments in renewable energy. This helps us test multiple stories and pin ordering functionality.",
tone: 'developing' as const,
priority: 'medium' as const,
timestamp: new Date(Date.now() - 2000),
duration: 25,
originalItem: {
title: "Renewable Energy Progress",
author: "energy_reporter",
subreddit: "energy"
},
sentiment: 'positive' as const,
topics: ['Energy', 'Environment']
},
{
id: `test-story-3-${Date.now()}`,
narrative: "Third test story about space exploration and recent discoveries. Perfect for testing the pin functionality with multiple stories.",
tone: 'analysis' as const,
priority: 'low' as const,
timestamp: new Date(Date.now() - 3000),
duration: 35,
originalItem: {
title: "Space Exploration Update",
author: "space_reporter",
subreddit: "space"
},
sentiment: 'neutral' as const,
topics: ['Space', 'Science']
}
];

set((state) => {
const newHistory = [...testStories, ...state.storyHistory];
const trimmedHistory = newHistory.slice(0, state.maxStoryHistory);
console.log(`🧪 Added ${testStories.length} test stories for pin functionality testing`);
return { storyHistory: trimmedHistory };
});
},

// Convex integration methods
loadStoriesFromConvex: async () => {
try {
Expand Down Expand Up @@ -727,4 +795,81 @@ export const useSimpleLiveFeedStore = create<SimpleLiveFeedStore>((set, get) =>
// Don't throw - this shouldn't break the normal flow
}
},

// Pin actions
pinStory: (storyId: string) => {
set((state) => {
const story = state.storyHistory.find(s => s.id === storyId);
if (!story || story.isPinned) {
return state; // Story not found or already pinned
}

// Find the next pinned order number
const pinnedStories = state.storyHistory.filter(s => s.isPinned);
const nextPinnedOrder = pinnedStories.length > 0
? Math.max(...pinnedStories.map(s => s.pinnedOrder || 0)) + 1
: 1;

const updatedHistory = state.storyHistory.map(s =>
s.id === storyId
? { ...s, isPinned: true, pinnedOrder: nextPinnedOrder }
: s
);

console.log(`📌 Pinned story: ${story.narrative.substring(0, 30)}... (Order: ${nextPinnedOrder})`);
return { storyHistory: updatedHistory };
});
},

unpinStory: (storyId: string) => {
set((state) => {
const story = state.storyHistory.find(s => s.id === storyId);
if (!story || !story.isPinned) {
return state; // Story not found or not pinned
}

const unpinnedOrder = story.pinnedOrder;

// Remove pin from target story and reorder remaining pinned stories
const updatedHistory = state.storyHistory.map(s => {
if (s.id === storyId) {
// Remove pin from target story
const { isPinned, pinnedOrder, ...storyWithoutPin } = s;
return storyWithoutPin;
} else if (s.isPinned && s.pinnedOrder && unpinnedOrder && s.pinnedOrder > unpinnedOrder) {
// Shift down stories that were after the unpinned story
return { ...s, pinnedOrder: s.pinnedOrder - 1 };
}
return s;
});

console.log(`📌 Unpinned story: ${story.narrative.substring(0, 30)}...`);
return { storyHistory: updatedHistory };
});
},

removeStory: (storyId: string) => {
set((state) => {
const story = state.storyHistory.find(s => s.id === storyId);
if (!story) {
return state; // Story not found
}

// If removing a pinned story, reorder remaining pinned stories
const removedPinnedOrder = story.isPinned ? story.pinnedOrder : null;

const updatedHistory = state.storyHistory
.filter(s => s.id !== storyId) // Remove the story
.map(s => {
// Reorder remaining pinned stories if needed
if (s.isPinned && s.pinnedOrder && removedPinnedOrder && s.pinnedOrder > removedPinnedOrder) {
return { ...s, pinnedOrder: s.pinnedOrder - 1 };
}
return s;
});

console.log(`🗑️ Removed story: ${story.narrative.substring(0, 30)}...`);
return { storyHistory: updatedHistory };
});
},
}));
Loading