+55
-2
src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts
+55
-2
src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts
···
9
9
} from '../../../domain/ICardQueryRepository';
10
10
import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle';
11
11
import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService';
12
+
import { IProfileService } from '../../../domain/services/IProfileService';
13
+
import { User } from '@semble/types';
12
14
13
15
export interface GetUrlCardsQuery {
14
16
userId: string;
···
20
22
}
21
23
22
24
// Enriched data for the final use case result
23
-
export type UrlCardListItemDTO = UrlCardView & WithCollections;
25
+
export type UrlCardListItemDTO = Omit<UrlCardView, 'authorId'> & {
26
+
author: User;
27
+
};
24
28
export interface GetUrlCardsResult {
25
29
cards: UrlCardListItemDTO[];
26
30
pagination: {
···
49
53
constructor(
50
54
private cardQueryRepo: ICardQueryRepository,
51
55
private identityResolver: IIdentityResolutionService,
56
+
private profileService: IProfileService,
52
57
) {}
53
58
54
59
async execute(query: GetUrlCardsQuery): Promise<Result<GetUrlCardsResult>> {
···
89
94
query.callingUserId,
90
95
);
91
96
97
+
// Get unique author IDs
98
+
const uniqueAuthorIds = Array.from(
99
+
new Set(result.items.map((item) => item.authorId)),
100
+
);
101
+
102
+
// Fetch author profiles
103
+
const profilePromises = uniqueAuthorIds.map((authorId) =>
104
+
this.profileService.getProfile(authorId, query.callingUserId),
105
+
);
106
+
107
+
const profileResults = await Promise.all(profilePromises);
108
+
109
+
// Create a map of profiles
110
+
const profileMap = new Map<string, User>();
111
+
112
+
for (let i = 0; i < uniqueAuthorIds.length; i++) {
113
+
const profileResult = profileResults[i];
114
+
const authorId = uniqueAuthorIds[i];
115
+
if (!profileResult || !authorId) {
116
+
return err(new Error('Missing profile result or author ID'));
117
+
}
118
+
if (profileResult.isErr()) {
119
+
return err(
120
+
new Error(
121
+
`Failed to fetch author profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`,
122
+
),
123
+
);
124
+
}
125
+
const profile = profileResult.value;
126
+
profileMap.set(authorId, {
127
+
id: profile.id,
128
+
name: profile.name,
129
+
handle: profile.handle,
130
+
avatarUrl: profile.avatarUrl,
131
+
description: profile.bio,
132
+
});
133
+
}
134
+
92
135
// Transform raw data to enriched DTOs
93
-
const enrichedCards: UrlCardListItemDTO[] = result.items;
136
+
const enrichedCards: UrlCardListItemDTO[] = result.items.map((item) => {
137
+
const author = profileMap.get(item.authorId);
138
+
if (!author) {
139
+
throw new Error(`Profile not found for author ${item.authorId}`);
140
+
}
141
+
142
+
return {
143
+
...item,
144
+
author,
145
+
};
146
+
});
94
147
95
148
return ok({
96
149
cards: enrichedCards,
+54
-5
src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts
+54
-5
src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts
···
3
3
import { InMemoryCardRepository } from '../utils/InMemoryCardRepository';
4
4
import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';
5
5
import { FakeIdentityResolutionService } from '../utils/FakeIdentityResolutionService';
6
+
import { FakeProfileService } from '../utils/FakeProfileService';
6
7
import { CuratorId } from '../../domain/value-objects/CuratorId';
7
8
import { Card } from '../../domain/Card';
8
9
import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType';
···
18
19
let cardRepo: InMemoryCardRepository;
19
20
let collectionRepo: InMemoryCollectionRepository;
20
21
let identityResolutionService: FakeIdentityResolutionService;
22
+
let profileService: FakeProfileService;
21
23
let curatorId: CuratorId;
22
24
23
25
beforeEach(() => {
···
25
27
collectionRepo = InMemoryCollectionRepository.getInstance();
26
28
cardQueryRepo = new InMemoryCardQueryRepository(cardRepo, collectionRepo);
27
29
identityResolutionService = new FakeIdentityResolutionService();
28
-
useCase = new GetUrlCardsUseCase(cardQueryRepo, identityResolutionService);
30
+
profileService = new FakeProfileService();
31
+
useCase = new GetUrlCardsUseCase(
32
+
cardQueryRepo,
33
+
identityResolutionService,
34
+
profileService,
35
+
);
29
36
30
37
curatorId = CuratorId.create('did:plc:testcurator').unwrap();
38
+
39
+
// Add the test curator profile to the profile service
40
+
profileService.addProfile({
41
+
id: curatorId.value,
42
+
name: 'Test Curator',
43
+
handle: 'testcurator.bsky.social',
44
+
avatarUrl: 'https://example.com/avatar.jpg',
45
+
bio: 'Test curator bio',
46
+
});
31
47
});
32
48
33
49
afterEach(() => {
···
35
51
collectionRepo.clear();
36
52
cardQueryRepo.clear();
37
53
identityResolutionService.clear();
54
+
profileService.clear();
38
55
});
39
56
40
57
describe('Basic functionality', () => {
···
151
168
expect(firstCard?.cardContent.title).toBe('First Article');
152
169
expect(firstCard?.cardContent.author).toBe('John Doe');
153
170
expect(firstCard?.libraryCount).toBe(1);
171
+
expect(firstCard?.author.id).toBe(curatorId.value);
172
+
expect(firstCard?.author.name).toBe('Test Curator');
173
+
expect(firstCard?.author.handle).toBe('testcurator.bsky.social');
154
174
155
175
expect(secondCard).toBeDefined();
156
176
expect(secondCard?.cardContent.title).toBe('Second Article');
157
177
expect(secondCard?.libraryCount).toBe(1);
178
+
expect(secondCard?.author.id).toBe(curatorId.value);
179
+
expect(secondCard?.author.name).toBe('Test Curator');
158
180
});
159
181
160
-
it('should include collections and notes in URL cards', async () => {
182
+
it('should include notes in URL cards (collections no longer included)', async () => {
161
183
// Create URL metadata
162
184
const urlMetadata = UrlMetadata.create({
163
185
url: 'https://example.com/article-with-extras',
···
212
234
expect(response.cards).toHaveLength(1);
213
235
214
236
const card = response.cards[0]!;
215
-
expect(card.collections).toHaveLength(0); // No collections created in this simplified test
216
237
expect(card.note).toBeUndefined(); // No note created in this simplified test
238
+
expect(card.author.id).toBe(curatorId.value);
239
+
expect(card.author.name).toBe('Test Curator');
217
240
});
218
241
219
242
it('should only return URL cards for the specified user', async () => {
220
243
const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
221
244
245
+
// Add the other curator profile to the profile service
246
+
profileService.addProfile({
247
+
id: otherCuratorId.value,
248
+
name: 'Other Curator',
249
+
handle: 'othercurator.bsky.social',
250
+
avatarUrl: 'https://example.com/other-avatar.jpg',
251
+
bio: 'Other curator bio',
252
+
});
253
+
222
254
// Create my card
223
255
const myUrlMetadata = UrlMetadata.create({
224
256
url: 'https://example.com/my-article',
···
296
328
const response = result.unwrap();
297
329
expect(response.cards).toHaveLength(1);
298
330
expect(response.cards[0]?.cardContent.title).toBe('My Article');
331
+
expect(response.cards[0]?.author.id).toBe(curatorId.value);
332
+
expect(response.cards[0]?.author.name).toBe('Test Curator');
299
333
});
300
334
});
301
335
···
568
602
expect(response.cards[0]?.cardContent.title).toBe('Gamma Article'); // most recent
569
603
expect(response.cards[1]?.cardContent.title).toBe('Alpha Article');
570
604
expect(response.cards[2]?.cardContent.title).toBe('Beta Article'); // oldest
605
+
// Verify all cards have author info
606
+
response.cards.forEach((card) => {
607
+
expect(card.author.id).toBe(curatorId.value);
608
+
expect(card.author.name).toBe('Test Curator');
609
+
});
571
610
});
572
611
573
612
it('should sort by created date ascending', async () => {
···
584
623
expect(response.cards[0]?.cardContent.title).toBe('Alpha Article'); // oldest
585
624
expect(response.cards[1]?.cardContent.title).toBe('Beta Article');
586
625
expect(response.cards[2]?.cardContent.title).toBe('Gamma Article'); // newest
626
+
// Verify all cards have author info
627
+
response.cards.forEach((card) => {
628
+
expect(card.author.id).toBe(curatorId.value);
629
+
expect(card.author.name).toBe('Test Curator');
630
+
});
587
631
});
588
632
589
633
it('should use default sorting when not specified', async () => {
···
635
679
const errorUseCase = new GetUrlCardsUseCase(
636
680
errorRepo,
637
681
identityResolutionService,
682
+
profileService,
638
683
);
639
684
640
685
const query = {
···
691
736
expect(response.cards[0]?.cardContent.description).toBeUndefined();
692
737
expect(response.cards[0]?.cardContent.author).toBeUndefined();
693
738
expect(response.cards[0]?.cardContent.thumbnailUrl).toBeUndefined();
739
+
expect(response.cards[0]?.author.id).toBe(curatorId.value);
740
+
expect(response.cards[0]?.author.name).toBe('Test Curator');
694
741
});
695
742
696
-
it('should handle empty collections array', async () => {
743
+
it('should handle cards without notes', async () => {
697
744
// Create URL metadata
698
745
const urlMetadata = UrlMetadata.create({
699
746
url: 'https://example.com/no-collections',
···
736
783
expect(result.isOk()).toBe(true);
737
784
const response = result.unwrap();
738
785
expect(response.cards).toHaveLength(1);
739
-
expect(response.cards[0]?.collections).toEqual([]);
786
+
// Collections are no longer included in the URL cards response
787
+
expect(response.cards[0]?.author.id).toBe(curatorId.value);
788
+
expect(response.cards[0]?.author.name).toBe('Test Curator');
740
789
});
741
790
});
742
791
});
+1
-1
src/types/src/api/responses.ts
+1
-1
src/types/src/api/responses.ts
+7
src/webapp/app/(dashboard)/url/error.tsx
+7
src/webapp/app/(dashboard)/url/error.tsx
+1
src/webapp/app/(dashboard)/url/page.tsx
+1
src/webapp/app/(dashboard)/url/page.tsx
+1
-2
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
+1
-2
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
+7
-127
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
+7
-127
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
···
1
1
import type { UrlCard } from '@/api-client';
2
2
import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays';
3
3
import { Modal, Stack, Text } from '@mantine/core';
4
-
import { notifications } from '@mantine/notifications';
5
-
import { Suspense, useState } from 'react';
6
-
import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector';
7
-
import CardToBeAddedPreview from '../cardToBeAddedPreview/CardToBeAddedPreview';
8
-
import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary';
9
-
import useMyCollections from '../../../collections/lib/queries/useMyCollections';
10
-
import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector';
11
-
import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations';
4
+
import { Suspense } from 'react';
12
5
import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector';
13
-
import useAddCard from '../../lib/mutations/useAddCard';
6
+
import AddCardToModalContent from './AddCardToModalContent'; // new file or inline
14
7
15
8
interface Props {
16
9
isOpen: boolean;
···
23
16
}
24
17
25
18
export default function AddCardToModal(props: Props) {
26
-
const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url });
27
-
const isMyCard = props.cardId === cardStatus.data.card?.id;
28
-
const [note, setNote] = useState(isMyCard ? props.note : '');
29
-
const { data, error } = useMyCollections();
30
-
31
-
const allCollections =
32
-
data?.pages.flatMap((page) => page.collections ?? []) ?? [];
33
-
34
-
const collectionsWithCard = allCollections.filter((c) =>
35
-
cardStatus.data.collections?.some((col) => col.id === c.id),
36
-
);
37
-
38
-
const [selectedCollections, setSelectedCollections] =
39
-
useState<SelectableCollectionItem[]>(collectionsWithCard);
40
-
41
-
const addCard = useAddCard();
42
-
const updateCardAssociations = useUpdateCardAssociations();
43
-
44
-
const handleUpdateCard = (e: React.FormEvent) => {
45
-
e.preventDefault();
46
-
47
-
const trimmedNote = note?.trimEnd() === '' ? undefined : note;
48
-
49
-
const addedCollections = selectedCollections.filter(
50
-
(c) => !collectionsWithCard.some((original) => original.id === c.id),
51
-
);
52
-
53
-
const removedCollections = collectionsWithCard.filter(
54
-
(c) => !selectedCollections.some((selected) => selected.id === c.id),
55
-
);
56
-
57
-
const hasNoteChanged = trimmedNote !== props.note;
58
-
const hasAdded = addedCollections.length > 0;
59
-
const hasRemoved = removedCollections.length > 0;
60
-
61
-
if (cardStatus.data.card && !hasNoteChanged && !hasAdded && !hasRemoved) {
62
-
props.onClose();
63
-
return;
64
-
}
65
-
66
-
// if the card is not in library, add it instead of updating
67
-
if (!cardStatus.data.card) {
68
-
addCard.mutate(
69
-
{
70
-
url: props.cardContent.url,
71
-
note: trimmedNote,
72
-
collectionIds: selectedCollections.map((c) => c.id),
73
-
},
74
-
{
75
-
onError: () => {
76
-
notifications.show({
77
-
message: 'Could not add card.',
78
-
});
79
-
},
80
-
onSettled: () => {
81
-
props.onClose();
82
-
},
83
-
},
84
-
);
85
-
return;
86
-
}
87
-
88
-
// otherwise, update existing card associations
89
-
const updatedCardPayload: {
90
-
cardId: string;
91
-
note?: string;
92
-
addToCollectionIds?: string[];
93
-
removeFromCollectionIds?: string[];
94
-
} = { cardId: cardStatus.data.card!.id };
95
-
96
-
if (hasNoteChanged) updatedCardPayload.note = trimmedNote;
97
-
if (hasAdded)
98
-
updatedCardPayload.addToCollectionIds = addedCollections.map((c) => c.id);
99
-
if (hasRemoved)
100
-
updatedCardPayload.removeFromCollectionIds = removedCollections.map(
101
-
(c) => c.id,
102
-
);
103
-
104
-
updateCardAssociations.mutate(updatedCardPayload, {
105
-
onError: () => {
106
-
notifications.show({
107
-
message: 'Could not update card.',
108
-
});
109
-
},
110
-
onSettled: () => {
111
-
props.onClose();
112
-
},
113
-
});
114
-
};
115
-
116
-
if (error) {
117
-
return <CollectionSelectorError />;
118
-
}
119
-
120
19
return (
121
20
<Modal
122
21
opened={props.isOpen}
123
-
onClose={() => {
124
-
props.onClose();
125
-
setSelectedCollections(collectionsWithCard);
126
-
}}
22
+
onClose={props.onClose}
127
23
title={
128
24
<Stack gap={0}>
129
25
<Text fw={600}>Add or update card</Text>
130
-
<Text c={'gray'} fw={500}>
26
+
<Text c="gray" fw={500}>
131
27
{props.isInYourLibrary
132
28
? props.urlLibraryCount === 1
133
29
? 'Saved by you'
···
142
38
centered
143
39
onClick={(e) => e.stopPropagation()}
144
40
>
145
-
<Stack justify="space-between">
146
-
<CardToBeAddedPreview
147
-
cardContent={props.cardContent}
148
-
note={isMyCard ? note : undefined}
149
-
onUpdateNote={setNote}
150
-
/>
151
-
41
+
{props.isOpen && (
152
42
<Suspense fallback={<CollectionSelectorSkeleton />}>
153
-
<CollectionSelector
154
-
isOpen={true}
155
-
onClose={props.onClose}
156
-
onCancel={() => {
157
-
props.onClose();
158
-
setSelectedCollections(collectionsWithCard);
159
-
}}
160
-
onSave={handleUpdateCard}
161
-
selectedCollections={selectedCollections}
162
-
onSelectedCollectionsChange={setSelectedCollections}
163
-
/>
43
+
<AddCardToModalContent {...props} />
164
44
</Suspense>
165
-
</Stack>
45
+
)}
166
46
</Modal>
167
47
);
168
48
}
+143
src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx
+143
src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx
···
1
+
'use client';
2
+
3
+
import type { UrlCard } from '@/api-client';
4
+
import { Stack } from '@mantine/core';
5
+
import { notifications } from '@mantine/notifications';
6
+
import { useState } from 'react';
7
+
import CollectionSelectorError from '@/features/collections/components/collectionSelector/Error.CollectionSelector';
8
+
import CardToBeAddedPreview from '@/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview';
9
+
import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector';
10
+
import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary';
11
+
import useMyCollections from '@/features/collections/lib/queries/useMyCollections';
12
+
import useUpdateCardAssociations from '@/features/cards/lib/mutations/useUpdateCardAssociations';
13
+
import useAddCard from '@/features/cards/lib/mutations/useAddCard';
14
+
15
+
interface SelectableCollectionItem {
16
+
id: string;
17
+
name: string;
18
+
cardCount: number;
19
+
}
20
+
21
+
interface Props {
22
+
onClose: () => void;
23
+
cardContent: UrlCard['cardContent'];
24
+
urlLibraryCount: number;
25
+
cardId: string;
26
+
note?: string;
27
+
isInYourLibrary: boolean;
28
+
}
29
+
30
+
export default function AddCardToModalContent(props: Props) {
31
+
const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url });
32
+
const isMyCard = props.cardId === cardStatus.data.card?.id;
33
+
const [note, setNote] = useState(isMyCard ? props.note : '');
34
+
const { data, error } = useMyCollections();
35
+
36
+
const addCard = useAddCard();
37
+
const updateCardAssociations = useUpdateCardAssociations();
38
+
39
+
if (error) {
40
+
return <CollectionSelectorError />;
41
+
}
42
+
43
+
const allCollections =
44
+
data?.pages.flatMap((page) => page.collections ?? []) ?? [];
45
+
46
+
const collectionsWithCard = allCollections.filter((c) =>
47
+
cardStatus.data.collections?.some((col) => col.id === c.id),
48
+
);
49
+
50
+
const [selectedCollections, setSelectedCollections] =
51
+
useState<SelectableCollectionItem[]>(collectionsWithCard);
52
+
53
+
const handleUpdateCard = (e: React.FormEvent) => {
54
+
e.preventDefault();
55
+
56
+
const trimmedNote = note?.trimEnd() === '' ? undefined : note;
57
+
58
+
const addedCollections = selectedCollections.filter(
59
+
(c) => !collectionsWithCard.some((original) => original.id === c.id),
60
+
);
61
+
62
+
const removedCollections = collectionsWithCard.filter(
63
+
(c) => !selectedCollections.some((selected) => selected.id === c.id),
64
+
);
65
+
66
+
const hasNoteChanged = trimmedNote !== props.note;
67
+
const hasAdded = addedCollections.length > 0;
68
+
const hasRemoved = removedCollections.length > 0;
69
+
70
+
// no change, close modal
71
+
if (cardStatus.data.card && !hasNoteChanged && !hasAdded && !hasRemoved) {
72
+
props.onClose();
73
+
return;
74
+
}
75
+
76
+
// card not yet in library, add it
77
+
if (!cardStatus.data.card) {
78
+
addCard.mutate(
79
+
{
80
+
url: props.cardContent.url,
81
+
note: trimmedNote,
82
+
collectionIds: selectedCollections.map((c) => c.id),
83
+
},
84
+
{
85
+
onError: () => {
86
+
notifications.show({ message: 'Could not add card.' });
87
+
},
88
+
onSettled: () => {
89
+
props.onClose();
90
+
},
91
+
},
92
+
);
93
+
return;
94
+
}
95
+
96
+
// card already in library, update associations instead
97
+
const updatedCardPayload: {
98
+
cardId: string;
99
+
note?: string;
100
+
addToCollectionIds?: string[];
101
+
removeFromCollectionIds?: string[];
102
+
} = { cardId: cardStatus.data.card.id };
103
+
104
+
if (hasNoteChanged) updatedCardPayload.note = trimmedNote;
105
+
if (hasAdded)
106
+
updatedCardPayload.addToCollectionIds = addedCollections.map((c) => c.id);
107
+
if (hasRemoved)
108
+
updatedCardPayload.removeFromCollectionIds = removedCollections.map(
109
+
(c) => c.id,
110
+
);
111
+
112
+
updateCardAssociations.mutate(updatedCardPayload, {
113
+
onError: () => {
114
+
notifications.show({ message: 'Could not update card.' });
115
+
},
116
+
onSettled: () => {
117
+
props.onClose();
118
+
},
119
+
});
120
+
};
121
+
122
+
return (
123
+
<Stack justify="space-between">
124
+
<CardToBeAddedPreview
125
+
cardContent={props.cardContent}
126
+
note={isMyCard ? note : undefined}
127
+
onUpdateNote={setNote}
128
+
/>
129
+
130
+
<CollectionSelector
131
+
isOpen={true}
132
+
onClose={props.onClose}
133
+
onCancel={() => {
134
+
props.onClose();
135
+
setSelectedCollections(collectionsWithCard);
136
+
}}
137
+
onSave={handleUpdateCard}
138
+
selectedCollections={selectedCollections}
139
+
onSelectedCollectionsChange={setSelectedCollections}
140
+
/>
141
+
</Stack>
142
+
);
143
+
}
+12
-25
src/webapp/features/cards/components/urlCard/UrlCard.tsx
+12
-25
src/webapp/features/cards/components/urlCard/UrlCard.tsx
···
8
8
Group,
9
9
Anchor,
10
10
AspectRatio,
11
-
Skeleton,
12
11
Tooltip,
13
12
} from '@mantine/core';
14
13
import Link from 'next/link';
15
14
import UrlCardActions from '../urlCardActions/UrlCardActions';
16
-
import { MouseEvent, Suspense } from 'react';
15
+
import { MouseEvent } from 'react';
17
16
import { useRouter } from 'next/navigation';
18
17
import styles from './UrlCard.module.css';
19
18
···
23
22
url: string;
24
23
cardContent: UrlCard['cardContent'];
25
24
note?: UrlCard['note'];
26
-
collections?: Collection[];
27
25
currentCollection?: Collection;
28
26
urlLibraryCount: number;
29
27
urlIsInLibrary?: boolean;
···
97
95
)}
98
96
</Group>
99
97
100
-
<Suspense
101
-
fallback={
102
-
<Group justify="space-between">
103
-
<Group gap={'xs'}>
104
-
<Skeleton w={22} h={22} />
105
-
</Group>
106
-
<Skeleton w={22} h={22} />
107
-
</Group>
108
-
}
109
-
>
110
-
<UrlCardActions
111
-
cardAuthor={props.cardAuthor}
112
-
cardContent={props.cardContent}
113
-
cardCount={props.urlLibraryCount}
114
-
id={props.id}
115
-
authorHandle={props.authorHandle}
116
-
note={props.note}
117
-
currentCollection={props.currentCollection}
118
-
urlLibraryCount={props.urlLibraryCount}
119
-
urlIsInLibrary={props.urlIsInLibrary!!}
120
-
/>
121
-
</Suspense>
98
+
<UrlCardActions
99
+
cardAuthor={props.cardAuthor}
100
+
cardContent={props.cardContent}
101
+
cardCount={props.urlLibraryCount}
102
+
id={props.id}
103
+
authorHandle={props.authorHandle}
104
+
note={props.note}
105
+
currentCollection={props.currentCollection}
106
+
urlLibraryCount={props.urlLibraryCount}
107
+
urlIsInLibrary={props.urlIsInLibrary!!}
108
+
/>
122
109
</Stack>
123
110
</Card>
124
111
);
+1
-1
src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx
+1
-1
src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx
+1
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
+1
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
-8
src/webapp/features/collections/lib/queries/useMyCollections.tsx
-8
src/webapp/features/collections/lib/queries/useMyCollections.tsx
···
1
1
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
2
2
import { getMyCollections } from '../dal';
3
-
import { useAuth } from '@/hooks/useAuth';
4
3
5
4
interface Props {
6
5
limit?: number;
7
6
}
8
7
9
8
export default function useMyCollections(props?: Props) {
10
-
const { isAuthenticated } = useAuth();
11
-
12
-
if (!isAuthenticated) {
13
-
// don't trigger Suspense
14
-
return { data: null };
15
-
}
16
-
17
9
const limit = props?.limit ?? 15;
18
10
19
11
return useSuspenseInfiniteQuery({
+1
-1
src/webapp/features/home/containers/homeContainer/HomeContainer.tsx
+1
-1
src/webapp/features/home/containers/homeContainer/HomeContainer.tsx
+24
-28
src/webapp/features/notes/components/noteCardModal/NoteCardModal.tsx
+24
-28
src/webapp/features/notes/components/noteCardModal/NoteCardModal.tsx
···
30
30
<Modal
31
31
opened={props.isOpen}
32
32
onClose={props.onClose}
33
-
title={
34
-
<Stack gap={5}>
35
-
<Text fw={600}>Note</Text>
36
-
{props.cardAuthor && (
37
-
<Group gap={5}>
38
-
<Avatar
39
-
size={'sm'}
40
-
component={Link}
41
-
href={`/profile/${props.cardAuthor.handle}`}
42
-
target="_blank"
43
-
src={props.cardAuthor.avatarUrl}
44
-
alt={`${props.cardAuthor.name}'s' avatar`}
45
-
/>
46
-
<Anchor
47
-
component={Link}
48
-
href={`/profile/${props.cardAuthor.handle}`}
49
-
target="_blank"
50
-
fw={700}
51
-
c="blue"
52
-
lineClamp={1}
53
-
>
54
-
{props.cardAuthor.name}
55
-
</Anchor>
56
-
</Group>
57
-
)}
58
-
</Stack>
59
-
}
33
+
title="Note"
60
34
overlayProps={UPDATE_OVERLAY_PROPS}
61
35
centered
62
36
onClick={(e) => e.stopPropagation()}
63
37
>
64
-
<Stack gap={'xs'} mt={'xs'}>
38
+
<Stack gap={'xs'}>
39
+
{props.cardAuthor && (
40
+
<Group gap={5}>
41
+
<Avatar
42
+
size={'sm'}
43
+
component={Link}
44
+
href={`/profile/${props.cardAuthor.handle}`}
45
+
target="_blank"
46
+
src={props.cardAuthor.avatarUrl}
47
+
alt={`${props.cardAuthor.name}'s' avatar`}
48
+
/>
49
+
<Anchor
50
+
component={Link}
51
+
href={`/profile/${props.cardAuthor.handle}`}
52
+
target="_blank"
53
+
fw={700}
54
+
c="blue"
55
+
lineClamp={1}
56
+
>
57
+
{props.cardAuthor.name}
58
+
</Anchor>
59
+
</Group>
60
+
)}
65
61
{props.note && <Text fs={'italic'}>{props.note.text}</Text>}
66
62
<Card withBorder p={'xs'} radius={'lg'}>
67
63
<Stack>
-1
src/webapp/features/profile/containers/profileContainer/ProfileContainer.tsx
-1
src/webapp/features/profile/containers/profileContainer/ProfileContainer.tsx
-8
src/webapp/features/profile/lib/queries/useMyProfile.tsx
-8
src/webapp/features/profile/lib/queries/useMyProfile.tsx
···
1
1
import { useSuspenseQuery, useQuery } from '@tanstack/react-query';
2
2
import { getMyProfile } from '../dal';
3
-
import { useAuth } from '@/hooks/useAuth';
4
3
5
4
export default function useMyProfile() {
6
-
const { isAuthenticated } = useAuth();
7
-
8
-
if (!isAuthenticated) {
9
-
// don't trigger Suspense
10
-
return { data: null };
11
-
}
12
-
13
5
return useSuspenseQuery({
14
6
queryKey: ['my profile'],
15
7
queryFn: () => getMyProfile(),
+5
src/webapp/features/semble/containers/sembleContainer/Error.SembleContainer.tsx
+5
src/webapp/features/semble/containers/sembleContainer/Error.SembleContainer.tsx
+41
-107
src/webapp/hooks/useAuth.tsx
+41
-107
src/webapp/hooks/useAuth.tsx
···
1
1
'use client';
2
2
3
-
import {
4
-
useState,
5
-
useEffect,
6
-
createContext,
7
-
useContext,
8
-
ReactNode,
9
-
useCallback,
10
-
} from 'react';
3
+
import { createContext, useContext, ReactNode, useEffect } from 'react';
4
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
11
5
import { useRouter } from 'next/navigation';
12
-
import { ClientCookieAuthService } from '@/services/auth';
13
-
import { ApiClient } from '@/api-client/ApiClient';
14
6
import type { GetProfileResponse } from '@/api-client/ApiClient';
7
+
import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client';
15
8
16
9
type UserProfile = GetProfileResponse;
17
10
18
-
interface AuthState {
11
+
interface AuthContextType {
12
+
user: UserProfile | null;
19
13
isAuthenticated: boolean;
20
14
isLoading: boolean;
21
-
user: UserProfile | null;
22
-
}
23
-
24
-
interface AuthContextType extends AuthState {
25
-
login: (handle: string) => Promise<{ authUrl: string }>;
15
+
refreshAuth: () => Promise<void>;
26
16
logout: () => Promise<void>;
27
-
refreshAuth: () => Promise<boolean>;
28
17
}
29
18
30
19
const AuthContext = createContext<AuthContextType | undefined>(undefined);
31
20
32
21
export const AuthProvider = ({ children }: { children: ReactNode }) => {
33
-
const [authState, setAuthState] = useState<AuthState>({
34
-
isAuthenticated: false,
35
-
isLoading: true,
36
-
user: null,
37
-
});
38
-
39
22
const router = useRouter();
23
+
const queryClient = useQueryClient();
40
24
41
-
// Refresh authentication (fetch user profile with automatic token refresh)
42
-
const refreshAuth = useCallback(async (): Promise<boolean> => {
43
-
try {
44
-
// Call /api/auth/me which handles token refresh + profile fetch
45
-
// HttpOnly cookies sent automatically with credentials: 'include'
25
+
const refreshAuth = async () => {
26
+
await query.refetch();
27
+
};
28
+
29
+
const logout = async () => {
30
+
await ClientCookieAuthService.clearTokens();
31
+
queryClient.removeQueries({ queryKey: ['authenticated user'] });
32
+
router.push('/login');
33
+
};
34
+
35
+
const query = useQuery<UserProfile | null>({
36
+
queryKey: ['authenticated user'],
37
+
queryFn: async () => {
46
38
const response = await fetch('/api/auth/me', {
47
39
method: 'GET',
48
-
credentials: 'include',
40
+
credentials: 'include', // HttpOnly cookies sent automatically
49
41
});
50
42
43
+
// unauthenticated
51
44
if (!response.ok) {
52
-
// Clear tokens when auth fails
53
-
await ClientCookieAuthService.clearTokens();
54
-
setAuthState({
55
-
isAuthenticated: false,
56
-
user: null,
57
-
isLoading: false,
58
-
});
59
-
return false;
45
+
throw new Error('Not authenticated');
60
46
}
61
47
62
-
const { user } = await response.json();
63
-
64
-
setAuthState({
65
-
isAuthenticated: true,
66
-
user,
67
-
isLoading: false,
68
-
});
69
-
70
-
return true;
71
-
} catch (error) {
72
-
console.error('Auth refresh failed:', error);
73
-
// Clear tokens on error too
74
-
await ClientCookieAuthService.clearTokens();
75
-
setAuthState({
76
-
isAuthenticated: false,
77
-
user: null,
78
-
isLoading: false,
79
-
});
80
-
return false;
81
-
}
82
-
}, []);
48
+
const data = await response.json();
49
+
return data.user as UserProfile;
50
+
},
51
+
staleTime: 5 * 60 * 1000, // cache for 5 minutes
52
+
refetchOnWindowFocus: false,
53
+
retry: false,
54
+
});
83
55
84
-
// Initialize auth on mount
85
56
useEffect(() => {
86
-
refreshAuth();
87
-
}, [refreshAuth]);
88
-
89
-
// Periodic auth check (every 5 minutes)
90
-
useEffect(() => {
91
-
if (!authState.isAuthenticated) return;
92
-
93
-
const interval = setInterval(
94
-
async () => {
95
-
await refreshAuth();
96
-
},
97
-
5 * 60 * 1000,
98
-
); // Check every 5 minutes
99
-
100
-
return () => clearInterval(interval);
101
-
}, [authState.isAuthenticated, refreshAuth]);
102
-
103
-
const login = useCallback(async (handle: string) => {
104
-
const apiClient = new ApiClient(
105
-
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000',
106
-
);
107
-
return await apiClient.initiateOAuthSignIn({ handle });
108
-
}, []);
57
+
if (query.isError) logout();
58
+
}, [query.isError, logout]);
109
59
110
-
const logout = useCallback(async () => {
111
-
try {
112
-
await ClientCookieAuthService.clearTokens();
113
-
} catch (error) {
114
-
console.error('Logout error:', error);
115
-
} finally {
116
-
setAuthState({
117
-
isAuthenticated: false,
118
-
isLoading: false,
119
-
user: null,
120
-
});
121
-
router.push('/login');
122
-
}
123
-
}, [router]);
60
+
const contextValue: AuthContextType = {
61
+
user: query.data ?? null,
62
+
isAuthenticated: !!query.data,
63
+
isLoading: query.isLoading,
64
+
refreshAuth,
65
+
logout,
66
+
};
124
67
125
68
return (
126
-
<AuthContext.Provider
127
-
value={{
128
-
...authState,
129
-
login,
130
-
logout,
131
-
refreshAuth,
132
-
}}
133
-
>
134
-
{children}
135
-
</AuthContext.Provider>
69
+
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
136
70
);
137
71
};
138
72
139
-
export const useAuth = () => {
73
+
export const useAuth = (): AuthContextType => {
140
74
const context = useContext(AuthContext);
141
75
if (!context) {
142
76
throw new Error('useAuth must be used within an AuthProvider');