mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -198,10 +198,17 @@ export class SDKAgent {
|
||||
type: 'new_observation',
|
||||
observation: {
|
||||
id: obsId,
|
||||
sdk_session_id: session.sdkSessionId,
|
||||
session_id: session.claudeSessionId,
|
||||
type: obs.type,
|
||||
title: obs.title,
|
||||
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,
|
||||
prompt_number: session.lastPromptNumber,
|
||||
created_at_epoch: createdAtEpoch
|
||||
|
||||
@@ -871,6 +871,49 @@
|
||||
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>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -72,12 +72,13 @@ export function App() {
|
||||
} catch (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(() => {
|
||||
handleLoadMore();
|
||||
}, [currentFilter, handleLoadMore]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentFilter]); // Only re-run when filter changes, not when handleLoadMore changes
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Observation, Summary, UserPrompt, FeedItem } from '../types';
|
||||
import { ObservationCard } from './ObservationCard';
|
||||
import { SummaryCard } from './SummaryCard';
|
||||
import { PromptCard } from './PromptCard';
|
||||
import { ScrollToTop } from './ScrollToTop';
|
||||
import { UI } from '../constants/ui';
|
||||
|
||||
interface FeedProps {
|
||||
@@ -16,6 +17,7 @@ interface FeedProps {
|
||||
|
||||
export function Feed({ observations, summaries, prompts, onLoadMore, isLoading, hasMore }: FeedProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const feedRef = useRef<HTMLDivElement>(null);
|
||||
const onLoadMoreRef = useRef(onLoadMore);
|
||||
|
||||
// Keep the callback ref up to date
|
||||
@@ -59,7 +61,8 @@ export function Feed({ observations, summaries, prompts, onLoadMore, isLoading,
|
||||
}, [observations, summaries, prompts]);
|
||||
|
||||
return (
|
||||
<div className="feed">
|
||||
<div className="feed" ref={feedRef}>
|
||||
<ScrollToTop targetRef={feedRef} />
|
||||
<div className="feed-content">
|
||||
{items.map(item => {
|
||||
const key = `${item.itemType}-${item.id}`;
|
||||
|
||||
57
src/ui/viewer/components/ScrollToTop.tsx
Normal file
57
src/ui/viewer/components/ScrollToTop.tsx
Normal file
@@ -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 { UI } from '../constants/ui';
|
||||
import { API_ENDPOINTS } from '../constants/api';
|
||||
@@ -21,6 +21,18 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
});
|
||||
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
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
@@ -34,17 +46,17 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
* Load more items from the API
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<DataItem[]> => {
|
||||
// Prevent concurrent requests using state
|
||||
if (state.isLoading || !state.hasMore) {
|
||||
// Prevent concurrent requests using ref (always current)
|
||||
if (stateRef.current.isLoading || !stateRef.current.hasMore) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// Build query params
|
||||
// Build query params using ref (always current)
|
||||
const params = new URLSearchParams({
|
||||
offset: offset.toString(),
|
||||
offset: offsetRef.current.toString(),
|
||||
limit: UI.PAGINATION_PAGE_SIZE.toString()
|
||||
});
|
||||
|
||||
@@ -74,7 +86,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return [];
|
||||
}
|
||||
}, [offset, state.hasMore, state.isLoading, currentFilter, endpoint, dataType]);
|
||||
}, [currentFilter, endpoint, dataType]); // Only stable values - no state/offset deps
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
Reference in New Issue
Block a user