tangled
alpha
login
or
join now
cosmik.network
/
semble
43
fork
atom
A social knowledge tool for researchers built on ATProto
43
fork
atom
overview
issues
13
pulls
pipelines
formatting fixes
Wesley Finck
7 months ago
b7d7835c
9878c377
+205
-160
11 changed files
expand all
collapse all
unified
split
src
webapp
app
(authenticated)
cards
add
page.tsx
collections
[collectionId]
edit
page.tsx
page.tsx
layout.tsx
components
AddToCollectionModal.tsx
CollectionSelector.tsx
UrlCardForm.tsx
UrlMetadataDisplay.tsx
hooks
useCollectionSearch.ts
useExtensionAuth.tsx
useUrlMetadata.ts
+9
-16
src/webapp/app/(authenticated)/cards/add/page.tsx
···
4
4
import { useRouter, useSearchParams } from 'next/navigation';
5
5
import { getAccessToken } from '@/services/auth';
6
6
import { ApiClient } from '@/api-client/ApiClient';
7
7
-
import {
8
8
-
Box,
9
9
-
Stack,
10
10
-
Text,
11
11
-
Title,
12
12
-
Card,
13
13
-
} from '@mantine/core';
7
7
+
import { Box, Stack, Text, Title, Card } from '@mantine/core';
14
8
import { UrlCardForm } from '@/components/UrlCardForm';
15
9
import { useAuth } from '@/hooks/useAuth';
16
10
···
18
12
const router = useRouter();
19
13
const searchParams = useSearchParams();
20
14
const { user } = useAuth();
21
21
-
15
15
+
22
16
const preSelectedCollectionId = searchParams.get('collectionId');
23
17
24
18
// Create API client instance - memoized to avoid recreating on every render
25
19
const apiClient = useMemo(
26
26
-
() => new ApiClient(
27
27
-
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
28
28
-
() => getAccessToken(),
29
29
-
),
30
30
-
[]
20
20
+
() =>
21
21
+
new ApiClient(
22
22
+
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
23
23
+
() => getAccessToken(),
24
24
+
),
25
25
+
[],
31
26
);
32
27
33
28
const handleSuccess = () => {
···
43
38
<Stack>
44
39
<Stack gap={0}>
45
40
<Title order={1}>Add Card</Title>
46
46
-
<Text c="gray">
47
47
-
Add a URL to your library with an optional note.
48
48
-
</Text>
41
41
+
<Text c="gray">Add a URL to your library with an optional note.</Text>
49
42
</Stack>
50
43
51
44
<Card withBorder>
+4
-1
src/webapp/app/(authenticated)/collections/[collectionId]/edit/page.tsx
···
126
126
{error && <Alert color="red" title={error} />}
127
127
128
128
{success && (
129
129
-
<Alert color="green" title="Collection updated successfully! Redirecting..." />
129
129
+
<Alert
130
130
+
color="green"
131
131
+
title="Collection updated successfully! Redirecting..."
132
132
+
/>
130
133
)}
131
134
132
135
<TextInput
+12
-2
src/webapp/app/(authenticated)/collections/[collectionId]/page.tsx
···
81
81
>
82
82
Edit Collection
83
83
</Button>
84
84
-
<Button onClick={() => router.push(`/cards/add?collectionId=${collectionId}`)}>Add Card</Button>
84
84
+
<Button
85
85
+
onClick={() =>
86
86
+
router.push(`/cards/add?collectionId=${collectionId}`)
87
87
+
}
88
88
+
>
89
89
+
Add Card
90
90
+
</Button>
85
91
</Group>
86
92
</Group>
87
93
···
146
152
) : (
147
153
<Stack align="center">
148
154
<Text c={'gray'}>No cards in this collection yet</Text>
149
149
-
<Button onClick={() => router.push(`/cards/add?collectionId=${collectionId}`)}>
155
155
+
<Button
156
156
+
onClick={() =>
157
157
+
router.push(`/cards/add?collectionId=${collectionId}`)
158
158
+
}
159
159
+
>
150
160
Add Your First Card
151
161
</Button>
152
162
</Stack>
+8
-1
src/webapp/app/(authenticated)/layout.tsx
···
4
4
import { usePathname, useRouter } from 'next/navigation';
5
5
import { useAuth } from '@/hooks/useAuth';
6
6
import { useDisclosure, useMediaQuery } from '@mantine/hooks';
7
7
-
import { ActionIcon, AppShell, Group, NavLink, Text, Affix } from '@mantine/core';
7
7
+
import {
8
8
+
ActionIcon,
9
9
+
AppShell,
10
10
+
Group,
11
11
+
NavLink,
12
12
+
Text,
13
13
+
Affix,
14
14
+
} from '@mantine/core';
8
15
import { FiSidebar } from 'react-icons/fi';
9
16
import { IoDocumentTextOutline } from 'react-icons/io5';
10
17
import { BsFolder2 } from 'react-icons/bs';
+9
-9
src/webapp/components/AddToCollectionModal.tsx
···
3
3
import { useState, useEffect, useMemo } from 'react';
4
4
import { getAccessToken } from '@/services/auth';
5
5
import { ApiClient } from '@/api-client/ApiClient';
6
6
-
import {
7
7
-
Button,
8
8
-
Group,
9
9
-
Modal,
10
10
-
Stack,
11
11
-
Text,
12
12
-
} from '@mantine/core';
6
6
+
import { Button, Group, Modal, Stack, Text } from '@mantine/core';
13
7
import { CollectionSelector } from './CollectionSelector';
14
8
15
9
interface Collection {
···
33
27
onClose,
34
28
onSuccess,
35
29
}: AddToCollectionModalProps) {
36
36
-
const [selectedCollectionIds, setSelectedCollectionIds] = useState<string[]>([]);
30
30
+
const [selectedCollectionIds, setSelectedCollectionIds] = useState<string[]>(
31
31
+
[],
32
32
+
);
37
33
const [submitting, setSubmitting] = useState(false);
38
34
const [error, setError] = useState('');
39
35
const [card, setCard] = useState<any>(null);
···
141
137
placeholder="Search collections to add..."
142
138
/>
143
139
144
144
-
{error && <Text c="red" size="sm">{error}</Text>}
140
140
+
{error && (
141
141
+
<Text c="red" size="sm">
142
142
+
{error}
143
143
+
</Text>
144
144
+
)}
145
145
146
146
<Group grow>
147
147
<Button
+31
-19
src/webapp/components/CollectionSelector.tsx
···
18
18
id: string;
19
19
name: string;
20
20
description?: string;
21
21
-
cardCount: number;
21
21
+
cardCount?: number;
22
22
authorId: string;
23
23
}
24
24
···
42
42
existingCollections = [],
43
43
disabled = false,
44
44
showCreateOption = true,
45
45
-
placeholder = "Search collections...",
45
45
+
placeholder = 'Search collections...',
46
46
preSelectedCollectionId,
47
47
}: CollectionSelectorProps) {
48
48
const [createModalOpen, setCreateModalOpen] = useState(false);
49
49
50
50
// Get existing collection IDs for filtering
51
51
const existingCollectionIds = useMemo(() => {
52
52
-
return existingCollections.map(collection => collection.id);
52
52
+
return existingCollections.map((collection) => collection.id);
53
53
}, [existingCollections]);
54
54
55
55
// Collection search hook
···
60
60
setSearchText,
61
61
handleSearchKeyPress,
62
62
loadCollections,
63
63
-
} = useCollectionSearch({
64
64
-
apiClient,
65
65
-
initialLoad: true
63
63
+
} = useCollectionSearch({
64
64
+
apiClient,
65
65
+
initialLoad: true,
66
66
});
67
67
68
68
// Filter out existing collections from search results
69
69
const availableCollections = useMemo(() => {
70
70
-
return allCollections.filter(collection => !existingCollectionIds.includes(collection.id));
70
70
+
return allCollections.filter(
71
71
+
(collection) => !existingCollectionIds.includes(collection.id),
72
72
+
);
71
73
}, [allCollections, existingCollectionIds]);
72
74
73
75
const handleCollectionToggle = (collectionId: string) => {
74
76
const newSelection = selectedCollectionIds.includes(collectionId)
75
75
-
? selectedCollectionIds.filter(id => id !== collectionId)
77
77
+
? selectedCollectionIds.filter((id) => id !== collectionId)
76
78
: [...selectedCollectionIds, collectionId];
77
79
onSelectionChange(newSelection);
78
80
};
···
81
83
setCreateModalOpen(true);
82
84
};
83
85
84
84
-
const handleCreateCollectionSuccess = (collectionId: string, collectionName: string) => {
86
86
+
const handleCreateCollectionSuccess = (
87
87
+
collectionId: string,
88
88
+
collectionName: string,
89
89
+
) => {
85
90
onSelectionChange([...selectedCollectionIds, collectionId]);
86
91
loadCollections(searchText.trim() || undefined);
87
92
setCreateModalOpen(false);
···
98
103
{existingCollections.length > 0 && (
99
104
<Box>
100
105
<Text size="xs" c="dimmed" mb="xs">
101
101
-
Already in {existingCollections.length} collection{existingCollections.length !== 1 ? 's' : ''}:
106
106
+
Already in {existingCollections.length} collection
107
107
+
{existingCollections.length !== 1 ? 's' : ''}:
102
108
</Text>
103
109
<Group gap="xs">
104
110
{existingCollections.map((collection) => (
···
109
115
</Group>
110
116
</Box>
111
117
)}
112
112
-
118
118
+
113
119
<Text size="sm" c="dimmed">
114
114
-
{existingCollections.length > 0 ? 'Add to additional collections (optional)' : 'Select collections (optional)'}
120
120
+
{existingCollections.length > 0
121
121
+
? 'Add to additional collections (optional)'
122
122
+
: 'Select collections (optional)'}
115
123
</Text>
116
116
-
124
124
+
117
125
<TextInput
118
126
placeholder={placeholder}
119
127
value={searchText}
···
127
135
{availableCollections.length > 0 ? (
128
136
<Stack gap={0}>
129
137
<Text size="xs" c="dimmed" mb="xs">
130
130
-
{availableCollections.length} collection{availableCollections.length !== 1 ? 's' : ''} found
138
138
+
{availableCollections.length} collection
139
139
+
{availableCollections.length !== 1 ? 's' : ''} found
131
140
</Text>
132
141
{searchText.trim() && showCreateOption && (
133
142
<Box
···
162
171
p="sm"
163
172
style={{
164
173
cursor: 'pointer',
165
165
-
backgroundColor: selectedCollectionIds.includes(collection.id)
166
166
-
? 'var(--mantine-color-blue-0)'
167
167
-
: index % 2 === 0
168
168
-
? 'var(--mantine-color-gray-0)'
174
174
+
backgroundColor: selectedCollectionIds.includes(
175
175
+
collection.id,
176
176
+
)
177
177
+
? 'var(--mantine-color-blue-0)'
178
178
+
: index % 2 === 0
179
179
+
? 'var(--mantine-color-gray-0)'
169
180
: 'transparent',
170
181
borderRadius: '4px',
171
182
border: selectedCollectionIds.includes(collection.id)
···
237
248
</Stack>
238
249
) : (
239
250
<Text size="sm" c="dimmed" py="md" ta="center">
240
240
-
No collections found. You can create collections from your library.
251
251
+
No collections found. You can create collections from your
252
252
+
library.
241
253
</Text>
242
254
)}
243
255
</Box>
+13
-15
src/webapp/components/UrlCardForm.tsx
···
1
1
'use client';
2
2
3
3
import { useState, useMemo } from 'react';
4
4
-
import {
5
5
-
Stack,
6
6
-
TextInput,
7
7
-
Textarea,
8
8
-
Button,
9
9
-
Group,
10
10
-
Text,
11
11
-
} from '@mantine/core';
4
4
+
import { Stack, TextInput, Textarea, Button, Group, Text } from '@mantine/core';
12
5
import { useForm } from '@mantine/form';
13
6
import { ApiClient } from '@/api-client/ApiClient';
14
7
import { UrlMetadataDisplay } from './UrlMetadataDisplay';
···
48
41
const [loading, setLoading] = useState(false);
49
42
const [error, setError] = useState('');
50
43
const [selectedCollectionIds, setSelectedCollectionIds] = useState<string[]>(
51
51
-
preSelectedCollectionId ? [preSelectedCollectionId] : []
44
44
+
preSelectedCollectionId ? [preSelectedCollectionId] : [],
52
45
);
53
46
54
47
// URL metadata hook
55
55
-
const { metadata, existingCard, loading: metadataLoading, error: metadataError } = useUrlMetadata({
48
48
+
const {
49
49
+
metadata,
50
50
+
existingCard,
51
51
+
loading: metadataLoading,
52
52
+
error: metadataError,
53
53
+
} = useUrlMetadata({
56
54
apiClient,
57
55
url: form.getValues().url,
58
56
autoFetch: !!form.getValues().url,
···
61
59
// Get existing collections for this card (filtered by current user)
62
60
const existingCollections = useMemo(() => {
63
61
if (!existingCard || !userId) return [];
64
64
-
return existingCard.collections.filter(collection => collection.authorId === userId);
62
62
+
return existingCard.collections.filter(
63
63
+
(collection) => collection.authorId === userId,
64
64
+
);
65
65
}, [existingCard, userId]);
66
66
-
67
66
68
67
const handleSubmit = async (e: React.FormEvent) => {
69
68
e.preventDefault();
···
89
88
await apiClient.addUrlToLibrary({
90
89
url,
91
90
note: form.getValues().note.trim() || undefined,
92
92
-
collectionIds: selectedCollectionIds.length > 0 ? selectedCollectionIds : undefined,
91
91
+
collectionIds:
92
92
+
selectedCollectionIds.length > 0 ? selectedCollectionIds : undefined,
93
93
});
94
94
95
95
onSuccess?.();
···
100
100
setLoading(false);
101
101
}
102
102
};
103
103
-
104
103
105
104
return (
106
105
<>
···
177
176
</Group>
178
177
</Stack>
179
178
</form>
180
180
-
181
179
</>
182
180
);
183
181
}
+4
-4
src/webapp/components/UrlMetadataDisplay.tsx
···
72
72
)}
73
73
<Stack gap="xs">
74
74
<Stack gap={0}>
75
75
-
<Title
76
76
-
order={compact ? 4 : 3}
77
77
-
lineClamp={2}
78
78
-
fz={compact ? 'sm' : 'md'}
75
75
+
<Title
76
76
+
order={compact ? 4 : 3}
77
77
+
lineClamp={2}
78
78
+
fz={compact ? 'sm' : 'md'}
79
79
fw={500}
80
80
>
81
81
{metadata.title || 'Untitled'}
+42
-31
src/webapp/hooks/useCollectionSearch.ts
···
8
8
debounceMs?: number;
9
9
}
10
10
11
11
-
export function useCollectionSearch({
12
12
-
apiClient,
13
13
-
initialLoad = true,
14
14
-
debounceMs = 300
11
11
+
export function useCollectionSearch({
12
12
+
apiClient,
13
13
+
initialLoad = true,
14
14
+
debounceMs = 300,
15
15
}: UseCollectionSearchProps) {
16
16
-
const [collections, setCollections] = useState<GetMyCollectionsResponse['collections']>([]);
16
16
+
const [collections, setCollections] = useState<
17
17
+
GetMyCollectionsResponse['collections']
18
18
+
>([]);
17
19
const [loading, setLoading] = useState(false);
18
20
const [searchText, setSearchText] = useState('');
19
21
const [hasInitialized, setHasInitialized] = useState(false);
20
22
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
21
23
22
24
// Memoized search parameters to avoid unnecessary API calls
23
23
-
const searchParams = useMemo(() => ({
24
24
-
limit: 20,
25
25
-
sortBy: 'updatedAt' as const,
26
26
-
sortOrder: 'desc' as const,
27
27
-
}), []);
25
25
+
const searchParams = useMemo(
26
26
+
() => ({
27
27
+
limit: 20,
28
28
+
sortBy: 'updatedAt' as const,
29
29
+
sortOrder: 'desc' as const,
30
30
+
}),
31
31
+
[],
32
32
+
);
28
33
29
34
// Memoized load function that only changes when apiClient changes
30
30
-
const loadCollections = useCallback(async (search?: string) => {
31
31
-
setLoading(true);
32
32
-
try {
33
33
-
const response = await apiClient.getMyCollections({
34
34
-
...searchParams,
35
35
-
searchText: search || undefined,
36
36
-
});
37
37
-
setCollections(response.collections);
38
38
-
} catch (error) {
39
39
-
console.error('Error loading collections:', error);
40
40
-
// Don't clear collections on error, keep showing previous results
41
41
-
} finally {
42
42
-
setLoading(false);
43
43
-
}
44
44
-
}, [apiClient, searchParams]);
35
35
+
const loadCollections = useCallback(
36
36
+
async (search?: string) => {
37
37
+
setLoading(true);
38
38
+
try {
39
39
+
const response = await apiClient.getMyCollections({
40
40
+
...searchParams,
41
41
+
searchText: search || undefined,
42
42
+
});
43
43
+
setCollections(response.collections);
44
44
+
} catch (error) {
45
45
+
console.error('Error loading collections:', error);
46
46
+
// Don't clear collections on error, keep showing previous results
47
47
+
} finally {
48
48
+
setLoading(false);
49
49
+
}
50
50
+
},
51
51
+
[apiClient, searchParams],
52
52
+
);
45
53
46
54
// Initial load effect - only runs once when component mounts
47
55
useEffect(() => {
···
80
88
if (debounceTimeoutRef.current) {
81
89
clearTimeout(debounceTimeoutRef.current);
82
90
}
83
83
-
91
91
+
84
92
const trimmedSearch = searchText.trim();
85
93
loadCollections(trimmedSearch || undefined);
86
94
}, [searchText, loadCollections]);
···
91
99
}, []);
92
100
93
101
// Handle search on Enter key press (immediate search, no debounce)
94
94
-
const handleSearchKeyPress = useCallback((e: React.KeyboardEvent) => {
95
95
-
if (e.key === 'Enter') {
96
96
-
handleSearch();
97
97
-
}
98
98
-
}, [handleSearch]);
102
102
+
const handleSearchKeyPress = useCallback(
103
103
+
(e: React.KeyboardEvent) => {
104
104
+
if (e.key === 'Enter') {
105
105
+
handleSearch();
106
106
+
}
107
107
+
},
108
108
+
[handleSearch],
109
109
+
);
99
110
100
111
return {
101
112
collections,
+31
-29
src/webapp/hooks/useExtensionAuth.tsx
···
139
139
}
140
140
}, [initAuth]);
141
141
142
142
-
const loginWithAppPassword = useCallback(async (
143
143
-
identifier: string,
144
144
-
appPassword: string,
145
145
-
) => {
146
146
-
try {
147
147
-
setError(null);
148
148
-
setIsLoading(true);
142
142
+
const loginWithAppPassword = useCallback(
143
143
+
async (identifier: string, appPassword: string) => {
144
144
+
try {
145
145
+
setError(null);
146
146
+
setIsLoading(true);
149
147
150
150
-
// Use unauthenticated client for login
151
151
-
const unauthenticatedClient = createApiClient(null);
152
152
-
const response = await unauthenticatedClient.loginWithAppPassword({
153
153
-
identifier,
154
154
-
appPassword,
155
155
-
});
156
156
-
const { accessToken: newToken } = response;
148
148
+
// Use unauthenticated client for login
149
149
+
const unauthenticatedClient = createApiClient(null);
150
150
+
const response = await unauthenticatedClient.loginWithAppPassword({
151
151
+
identifier,
152
152
+
appPassword,
153
153
+
});
154
154
+
const { accessToken: newToken } = response;
157
155
158
158
-
setAccessToken(newToken);
159
159
-
await setStoredToken(newToken);
156
156
+
setAccessToken(newToken);
157
157
+
await setStoredToken(newToken);
160
158
161
161
-
// Create new authenticated client for profile fetch
162
162
-
const authenticatedClient = createApiClient(newToken);
163
163
-
const userData = await authenticatedClient.getMyProfile();
164
164
-
setUser(userData);
165
165
-
setIsAuthenticated(true);
166
166
-
} catch (error: any) {
167
167
-
console.error('App password login failed:', error);
168
168
-
setError(error.message || 'Login failed. Please check your credentials.');
169
169
-
throw error;
170
170
-
} finally {
171
171
-
setIsLoading(false);
172
172
-
}
173
173
-
}, [createApiClient, setStoredToken]);
159
159
+
// Create new authenticated client for profile fetch
160
160
+
const authenticatedClient = createApiClient(newToken);
161
161
+
const userData = await authenticatedClient.getMyProfile();
162
162
+
setUser(userData);
163
163
+
setIsAuthenticated(true);
164
164
+
} catch (error: any) {
165
165
+
console.error('App password login failed:', error);
166
166
+
setError(
167
167
+
error.message || 'Login failed. Please check your credentials.',
168
168
+
);
169
169
+
throw error;
170
170
+
} finally {
171
171
+
setIsLoading(false);
172
172
+
}
173
173
+
},
174
174
+
[createApiClient, setStoredToken],
175
175
+
);
174
176
175
177
const logout = useCallback(async () => {
176
178
try {
+42
-33
src/webapp/hooks/useUrlMetadata.ts
···
9
9
autoFetch?: boolean;
10
10
}
11
11
12
12
-
export function useUrlMetadata({ apiClient, url, autoFetch = true }: UseUrlMetadataProps) {
12
12
+
export function useUrlMetadata({
13
13
+
apiClient,
14
14
+
url,
15
15
+
autoFetch = true,
16
16
+
}: UseUrlMetadataProps) {
13
17
const [metadata, setMetadata] = useState<UrlMetadata | null>(null);
14
18
const [existingCard, setExistingCard] = useState<UrlCardView | null>(null);
15
19
const [loading, setLoading] = useState(false);
16
20
const [error, setError] = useState<string | null>(null);
17
21
18
18
-
const fetchMetadata = useCallback(async (targetUrl: string) => {
19
19
-
if (!targetUrl.trim()) return;
22
22
+
const fetchMetadata = useCallback(
23
23
+
async (targetUrl: string) => {
24
24
+
if (!targetUrl.trim()) return;
20
25
21
21
-
// Basic URL validation
22
22
-
try {
23
23
-
new URL(targetUrl);
24
24
-
} catch {
25
25
-
setError('Invalid URL format');
26
26
-
return;
27
27
-
}
26
26
+
// Basic URL validation
27
27
+
try {
28
28
+
new URL(targetUrl);
29
29
+
} catch {
30
30
+
setError('Invalid URL format');
31
31
+
return;
32
32
+
}
28
33
29
29
-
setLoading(true);
30
30
-
setError(null);
31
31
-
setExistingCard(null);
34
34
+
setLoading(true);
35
35
+
setError(null);
36
36
+
setExistingCard(null);
32
37
33
33
-
try {
34
34
-
const response = await apiClient.getUrlMetadata(targetUrl);
35
35
-
setMetadata(response.metadata);
38
38
+
try {
39
39
+
const response = await apiClient.getUrlMetadata(targetUrl);
40
40
+
setMetadata(response.metadata);
36
41
37
37
-
// If there's an existing card, fetch its details including collections
38
38
-
if (response.existingCardId) {
39
39
-
try {
40
40
-
const cardResponse = await apiClient.getUrlCardView(response.existingCardId);
41
41
-
setExistingCard(cardResponse);
42
42
-
} catch (cardErr: any) {
43
43
-
console.error('Failed to fetch existing card details:', cardErr);
44
44
-
// Don't set error here as the metadata fetch was successful
42
42
+
// If there's an existing card, fetch its details including collections
43
43
+
if (response.existingCardId) {
44
44
+
try {
45
45
+
const cardResponse = await apiClient.getUrlCardView(
46
46
+
response.existingCardId,
47
47
+
);
48
48
+
setExistingCard(cardResponse);
49
49
+
} catch (cardErr: any) {
50
50
+
console.error('Failed to fetch existing card details:', cardErr);
51
51
+
// Don't set error here as the metadata fetch was successful
52
52
+
}
45
53
}
54
54
+
} catch (err: any) {
55
55
+
console.error('Failed to fetch URL metadata:', err);
56
56
+
setError('Failed to load page information');
57
57
+
setMetadata(null);
58
58
+
setExistingCard(null);
59
59
+
} finally {
60
60
+
setLoading(false);
46
61
}
47
47
-
} catch (err: any) {
48
48
-
console.error('Failed to fetch URL metadata:', err);
49
49
-
setError('Failed to load page information');
50
50
-
setMetadata(null);
51
51
-
setExistingCard(null);
52
52
-
} finally {
53
53
-
setLoading(false);
54
54
-
}
55
55
-
}, [apiClient]);
62
62
+
},
63
63
+
[apiClient],
64
64
+
);
56
65
57
66
// Auto-fetch when URL changes
58
67
useEffect(() => {