feat: add scroll-to-top button and improve pagination handling
- Implemented a scroll-to-top button in the viewer UI for better navigation. - Added styles for the scroll-to-top button in viewer.html and viewer-template.html. - Created a new ScrollToTop component to manage visibility and scrolling behavior. - Updated Feed component to include the ScrollToTop component. - Enhanced pagination logic in usePagination hook to prevent stale closures and improve performance. - Modified SDKAgent to include additional observation fields for better data handling.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -871,6 +871,49 @@
|
|||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scroll to top button */
|
||||||
|
.scroll-to-top {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--color-bg-button);
|
||||||
|
color: var(--color-text-button);
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 50;
|
||||||
|
animation: fadeInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-top:hover {
|
||||||
|
background: var(--color-bg-button-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-top:active {
|
||||||
|
background: var(--color-bg-button-active);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -198,10 +198,17 @@ export class SDKAgent {
|
|||||||
type: 'new_observation',
|
type: 'new_observation',
|
||||||
observation: {
|
observation: {
|
||||||
id: obsId,
|
id: obsId,
|
||||||
|
sdk_session_id: session.sdkSessionId,
|
||||||
session_id: session.claudeSessionId,
|
session_id: session.claudeSessionId,
|
||||||
type: obs.type,
|
type: obs.type,
|
||||||
title: obs.title,
|
title: obs.title,
|
||||||
subtitle: obs.subtitle,
|
subtitle: obs.subtitle,
|
||||||
|
text: obs.text || null,
|
||||||
|
narrative: null,
|
||||||
|
facts: JSON.stringify(obs.facts || []),
|
||||||
|
concepts: JSON.stringify(obs.concepts || []),
|
||||||
|
files_read: JSON.stringify(obs.files || []),
|
||||||
|
files_modified: JSON.stringify([]),
|
||||||
project: session.project,
|
project: session.project,
|
||||||
prompt_number: session.lastPromptNumber,
|
prompt_number: session.lastPromptNumber,
|
||||||
created_at_epoch: createdAtEpoch
|
created_at_epoch: createdAtEpoch
|
||||||
|
|||||||
@@ -871,6 +871,49 @@
|
|||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scroll to top button */
|
||||||
|
.scroll-to-top {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--color-bg-button);
|
||||||
|
color: var(--color-text-button);
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 50;
|
||||||
|
animation: fadeInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-top:hover {
|
||||||
|
background: var(--color-bg-button-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-top:active {
|
||||||
|
background: var(--color-bg-button-active);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -72,12 +72,13 @@ export function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load more data:', error);
|
console.error('Failed to load more data:', error);
|
||||||
}
|
}
|
||||||
}, [pagination]);
|
}, [pagination.observations, pagination.summaries, pagination.prompts]);
|
||||||
|
|
||||||
// Load first page when filter changes or pagination handlers update
|
// Load first page only when filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoadMore();
|
handleLoadMore();
|
||||||
}, [currentFilter, handleLoadMore]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentFilter]); // Only re-run when filter changes, not when handleLoadMore changes
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Observation, Summary, UserPrompt, FeedItem } from '../types';
|
|||||||
import { ObservationCard } from './ObservationCard';
|
import { ObservationCard } from './ObservationCard';
|
||||||
import { SummaryCard } from './SummaryCard';
|
import { SummaryCard } from './SummaryCard';
|
||||||
import { PromptCard } from './PromptCard';
|
import { PromptCard } from './PromptCard';
|
||||||
|
import { ScrollToTop } from './ScrollToTop';
|
||||||
import { UI } from '../constants/ui';
|
import { UI } from '../constants/ui';
|
||||||
|
|
||||||
interface FeedProps {
|
interface FeedProps {
|
||||||
@@ -16,6 +17,7 @@ interface FeedProps {
|
|||||||
|
|
||||||
export function Feed({ observations, summaries, prompts, onLoadMore, isLoading, hasMore }: FeedProps) {
|
export function Feed({ observations, summaries, prompts, onLoadMore, isLoading, hasMore }: FeedProps) {
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const feedRef = useRef<HTMLDivElement>(null);
|
||||||
const onLoadMoreRef = useRef(onLoadMore);
|
const onLoadMoreRef = useRef(onLoadMore);
|
||||||
|
|
||||||
// Keep the callback ref up to date
|
// Keep the callback ref up to date
|
||||||
@@ -59,7 +61,8 @@ export function Feed({ observations, summaries, prompts, onLoadMore, isLoading,
|
|||||||
}, [observations, summaries, prompts]);
|
}, [observations, summaries, prompts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed" ref={feedRef}>
|
||||||
|
<ScrollToTop targetRef={feedRef} />
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{items.map(item => {
|
{items.map(item => {
|
||||||
const key = `${item.itemType}-${item.id}`;
|
const key = `${item.itemType}-${item.id}`;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ScrollToTopProps {
|
||||||
|
targetRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollToTop({ targetRef }: ScrollToTopProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const target = targetRef.current;
|
||||||
|
if (target) {
|
||||||
|
setIsVisible(target.scrollTop > 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = targetRef.current;
|
||||||
|
if (target) {
|
||||||
|
target.addEventListener('scroll', handleScroll);
|
||||||
|
return () => target.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
}, []); // Empty deps - only set up listener once on mount
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
const target = targetRef.current;
|
||||||
|
if (target) {
|
||||||
|
target.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className="scroll-to-top"
|
||||||
|
aria-label="Scroll to top"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="18 15 12 9 6 15"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Observation, Summary, UserPrompt } from '../types';
|
import { Observation, Summary, UserPrompt } from '../types';
|
||||||
import { UI } from '../constants/ui';
|
import { UI } from '../constants/ui';
|
||||||
import { API_ENDPOINTS } from '../constants/api';
|
import { API_ENDPOINTS } from '../constants/api';
|
||||||
@@ -21,6 +21,18 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
|||||||
});
|
});
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
// Use refs to avoid stale closures and prevent infinite loops
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
const offsetRef = useRef(offset);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
offsetRef.current = offset;
|
||||||
|
}, [offset]);
|
||||||
|
|
||||||
// Reset pagination when filter changes
|
// Reset pagination when filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOffset(0);
|
setOffset(0);
|
||||||
@@ -34,17 +46,17 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
|||||||
* Load more items from the API
|
* Load more items from the API
|
||||||
*/
|
*/
|
||||||
const loadMore = useCallback(async (): Promise<DataItem[]> => {
|
const loadMore = useCallback(async (): Promise<DataItem[]> => {
|
||||||
// Prevent concurrent requests using state
|
// Prevent concurrent requests using ref (always current)
|
||||||
if (state.isLoading || !state.hasMore) {
|
if (stateRef.current.isLoading || !stateRef.current.hasMore) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(prev => ({ ...prev, isLoading: true }));
|
setState(prev => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build query params
|
// Build query params using ref (always current)
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
offset: offset.toString(),
|
offset: offsetRef.current.toString(),
|
||||||
limit: UI.PAGINATION_PAGE_SIZE.toString()
|
limit: UI.PAGINATION_PAGE_SIZE.toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +86,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
|||||||
setState(prev => ({ ...prev, isLoading: false }));
|
setState(prev => ({ ...prev, isLoading: false }));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [offset, state.hasMore, state.isLoading, currentFilter, endpoint, dataType]);
|
}, [currentFilter, endpoint, dataType]); // Only stable values - no state/offset deps
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
Reference in New Issue
Block a user