A social knowledge tool for researchers built on ATProto

Merge branch 'development' into fix/oauth-session-locking

Changed files
+355 -316
src
modules
cards
application
useCases
tests
shared
infrastructure
http
factories
types
src
webapp
app
(dashboard)
features
cards
components
containers
cardsContainer
collections
containers
collectionContainer
lib
home
containers
homeContainer
notes
components
noteCardModal
profile
containers
profileContainer
lib
semble
containers
sembleContainer
hooks
+55 -2
src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts
··· 9 } from '../../../domain/ICardQueryRepository'; 10 import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; 11 import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; 12 13 export interface GetUrlCardsQuery { 14 userId: string; ··· 20 } 21 22 // Enriched data for the final use case result 23 - export type UrlCardListItemDTO = UrlCardView & WithCollections; 24 export interface GetUrlCardsResult { 25 cards: UrlCardListItemDTO[]; 26 pagination: { ··· 49 constructor( 50 private cardQueryRepo: ICardQueryRepository, 51 private identityResolver: IIdentityResolutionService, 52 ) {} 53 54 async execute(query: GetUrlCardsQuery): Promise<Result<GetUrlCardsResult>> { ··· 89 query.callingUserId, 90 ); 91 92 // Transform raw data to enriched DTOs 93 - const enrichedCards: UrlCardListItemDTO[] = result.items; 94 95 return ok({ 96 cards: enrichedCards,
··· 9 } from '../../../domain/ICardQueryRepository'; 10 import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; 11 import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; 12 + import { IProfileService } from '../../../domain/services/IProfileService'; 13 + import { User } from '@semble/types'; 14 15 export interface GetUrlCardsQuery { 16 userId: string; ··· 22 } 23 24 // Enriched data for the final use case result 25 + export type UrlCardListItemDTO = Omit<UrlCardView, 'authorId'> & { 26 + author: User; 27 + }; 28 export interface GetUrlCardsResult { 29 cards: UrlCardListItemDTO[]; 30 pagination: { ··· 53 constructor( 54 private cardQueryRepo: ICardQueryRepository, 55 private identityResolver: IIdentityResolutionService, 56 + private profileService: IProfileService, 57 ) {} 58 59 async execute(query: GetUrlCardsQuery): Promise<Result<GetUrlCardsResult>> { ··· 94 query.callingUserId, 95 ); 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 + 135 // Transform raw data to enriched DTOs 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 + }); 147 148 return ok({ 149 cards: enrichedCards,
+54 -5
src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts
··· 3 import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 4 import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 5 import { FakeIdentityResolutionService } from '../utils/FakeIdentityResolutionService'; 6 import { CuratorId } from '../../domain/value-objects/CuratorId'; 7 import { Card } from '../../domain/Card'; 8 import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; ··· 18 let cardRepo: InMemoryCardRepository; 19 let collectionRepo: InMemoryCollectionRepository; 20 let identityResolutionService: FakeIdentityResolutionService; 21 let curatorId: CuratorId; 22 23 beforeEach(() => { ··· 25 collectionRepo = InMemoryCollectionRepository.getInstance(); 26 cardQueryRepo = new InMemoryCardQueryRepository(cardRepo, collectionRepo); 27 identityResolutionService = new FakeIdentityResolutionService(); 28 - useCase = new GetUrlCardsUseCase(cardQueryRepo, identityResolutionService); 29 30 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 31 }); 32 33 afterEach(() => { ··· 35 collectionRepo.clear(); 36 cardQueryRepo.clear(); 37 identityResolutionService.clear(); 38 }); 39 40 describe('Basic functionality', () => { ··· 151 expect(firstCard?.cardContent.title).toBe('First Article'); 152 expect(firstCard?.cardContent.author).toBe('John Doe'); 153 expect(firstCard?.libraryCount).toBe(1); 154 155 expect(secondCard).toBeDefined(); 156 expect(secondCard?.cardContent.title).toBe('Second Article'); 157 expect(secondCard?.libraryCount).toBe(1); 158 }); 159 160 - it('should include collections and notes in URL cards', async () => { 161 // Create URL metadata 162 const urlMetadata = UrlMetadata.create({ 163 url: 'https://example.com/article-with-extras', ··· 212 expect(response.cards).toHaveLength(1); 213 214 const card = response.cards[0]!; 215 - expect(card.collections).toHaveLength(0); // No collections created in this simplified test 216 expect(card.note).toBeUndefined(); // No note created in this simplified test 217 }); 218 219 it('should only return URL cards for the specified user', async () => { 220 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 221 222 // Create my card 223 const myUrlMetadata = UrlMetadata.create({ 224 url: 'https://example.com/my-article', ··· 296 const response = result.unwrap(); 297 expect(response.cards).toHaveLength(1); 298 expect(response.cards[0]?.cardContent.title).toBe('My Article'); 299 }); 300 }); 301 ··· 568 expect(response.cards[0]?.cardContent.title).toBe('Gamma Article'); // most recent 569 expect(response.cards[1]?.cardContent.title).toBe('Alpha Article'); 570 expect(response.cards[2]?.cardContent.title).toBe('Beta Article'); // oldest 571 }); 572 573 it('should sort by created date ascending', async () => { ··· 584 expect(response.cards[0]?.cardContent.title).toBe('Alpha Article'); // oldest 585 expect(response.cards[1]?.cardContent.title).toBe('Beta Article'); 586 expect(response.cards[2]?.cardContent.title).toBe('Gamma Article'); // newest 587 }); 588 589 it('should use default sorting when not specified', async () => { ··· 635 const errorUseCase = new GetUrlCardsUseCase( 636 errorRepo, 637 identityResolutionService, 638 ); 639 640 const query = { ··· 691 expect(response.cards[0]?.cardContent.description).toBeUndefined(); 692 expect(response.cards[0]?.cardContent.author).toBeUndefined(); 693 expect(response.cards[0]?.cardContent.thumbnailUrl).toBeUndefined(); 694 }); 695 696 - it('should handle empty collections array', async () => { 697 // Create URL metadata 698 const urlMetadata = UrlMetadata.create({ 699 url: 'https://example.com/no-collections', ··· 736 expect(result.isOk()).toBe(true); 737 const response = result.unwrap(); 738 expect(response.cards).toHaveLength(1); 739 - expect(response.cards[0]?.collections).toEqual([]); 740 }); 741 }); 742 });
··· 3 import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 4 import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 5 import { FakeIdentityResolutionService } from '../utils/FakeIdentityResolutionService'; 6 + import { FakeProfileService } from '../utils/FakeProfileService'; 7 import { CuratorId } from '../../domain/value-objects/CuratorId'; 8 import { Card } from '../../domain/Card'; 9 import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; ··· 19 let cardRepo: InMemoryCardRepository; 20 let collectionRepo: InMemoryCollectionRepository; 21 let identityResolutionService: FakeIdentityResolutionService; 22 + let profileService: FakeProfileService; 23 let curatorId: CuratorId; 24 25 beforeEach(() => { ··· 27 collectionRepo = InMemoryCollectionRepository.getInstance(); 28 cardQueryRepo = new InMemoryCardQueryRepository(cardRepo, collectionRepo); 29 identityResolutionService = new FakeIdentityResolutionService(); 30 + profileService = new FakeProfileService(); 31 + useCase = new GetUrlCardsUseCase( 32 + cardQueryRepo, 33 + identityResolutionService, 34 + profileService, 35 + ); 36 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 + }); 47 }); 48 49 afterEach(() => { ··· 51 collectionRepo.clear(); 52 cardQueryRepo.clear(); 53 identityResolutionService.clear(); 54 + profileService.clear(); 55 }); 56 57 describe('Basic functionality', () => { ··· 168 expect(firstCard?.cardContent.title).toBe('First Article'); 169 expect(firstCard?.cardContent.author).toBe('John Doe'); 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'); 174 175 expect(secondCard).toBeDefined(); 176 expect(secondCard?.cardContent.title).toBe('Second Article'); 177 expect(secondCard?.libraryCount).toBe(1); 178 + expect(secondCard?.author.id).toBe(curatorId.value); 179 + expect(secondCard?.author.name).toBe('Test Curator'); 180 }); 181 182 + it('should include notes in URL cards (collections no longer included)', async () => { 183 // Create URL metadata 184 const urlMetadata = UrlMetadata.create({ 185 url: 'https://example.com/article-with-extras', ··· 234 expect(response.cards).toHaveLength(1); 235 236 const card = response.cards[0]!; 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'); 240 }); 241 242 it('should only return URL cards for the specified user', async () => { 243 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 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 + 254 // Create my card 255 const myUrlMetadata = UrlMetadata.create({ 256 url: 'https://example.com/my-article', ··· 328 const response = result.unwrap(); 329 expect(response.cards).toHaveLength(1); 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'); 333 }); 334 }); 335 ··· 602 expect(response.cards[0]?.cardContent.title).toBe('Gamma Article'); // most recent 603 expect(response.cards[1]?.cardContent.title).toBe('Alpha Article'); 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 + }); 610 }); 611 612 it('should sort by created date ascending', async () => { ··· 623 expect(response.cards[0]?.cardContent.title).toBe('Alpha Article'); // oldest 624 expect(response.cards[1]?.cardContent.title).toBe('Beta Article'); 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 + }); 631 }); 632 633 it('should use default sorting when not specified', async () => { ··· 679 const errorUseCase = new GetUrlCardsUseCase( 680 errorRepo, 681 identityResolutionService, 682 + profileService, 683 ); 684 685 const query = { ··· 736 expect(response.cards[0]?.cardContent.description).toBeUndefined(); 737 expect(response.cards[0]?.cardContent.author).toBeUndefined(); 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'); 741 }); 742 743 + it('should handle cards without notes', async () => { 744 // Create URL metadata 745 const urlMetadata = UrlMetadata.create({ 746 url: 'https://example.com/no-collections', ··· 783 expect(result.isOk()).toBe(true); 784 const response = result.unwrap(); 785 expect(response.cards).toHaveLength(1); 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'); 789 }); 790 }); 791 });
+1
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 172 getMyUrlCardsUseCase: new GetUrlCardsUseCase( 173 repositories.cardQueryRepository, 174 services.identityResolutionService, 175 ), 176 createCollectionUseCase: new CreateCollectionUseCase( 177 repositories.collectionRepository,
··· 172 getMyUrlCardsUseCase: new GetUrlCardsUseCase( 173 repositories.cardQueryRepository, 174 services.identityResolutionService, 175 + services.profileService, 176 ), 177 createCollectionUseCase: new CreateCollectionUseCase( 178 repositories.collectionRepository,
+1 -1
src/types/src/api/responses.ts
··· 128 export interface GetProfileResponse extends User {} 129 130 export interface GetUrlCardsResponse { 131 - cards: UrlCardWithCollections[]; 132 pagination: Pagination; 133 sorting: CardSorting; 134 }
··· 128 export interface GetProfileResponse extends User {} 129 130 export interface GetUrlCardsResponse { 131 + cards: UrlCard[]; 132 pagination: Pagination; 133 sorting: CardSorting; 134 }
+7
src/webapp/app/(dashboard)/url/error.tsx
···
··· 1 + 'use client'; 2 + 3 + import SembleContainerError from '@/features/semble/containers/sembleContainer/Error.SembleContainer'; 4 + 5 + export default function Error() { 6 + return <SembleContainerError />; 7 + }
+1
src/webapp/app/(dashboard)/url/page.tsx
··· 48 if (!url) { 49 redirect('/'); 50 } 51 52 return ( 53 <Fragment>
··· 48 if (!url) { 49 redirect('/'); 50 } 51 + 52 53 return ( 54 <Fragment>
+1 -2
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
··· 1 import { 2 - Button, 3 - Center, 4 Container, 5 Drawer, 6 Group,
··· 1 import { 2 + Button, 3 Container, 4 Drawer, 5 Group,
+7 -127
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 1 import type { UrlCard } from '@/api-client'; 2 import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 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'; 12 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 13 - import useAddCard from '../../lib/mutations/useAddCard'; 14 15 interface Props { 16 isOpen: boolean; ··· 23 } 24 25 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 return ( 121 <Modal 122 opened={props.isOpen} 123 - onClose={() => { 124 - props.onClose(); 125 - setSelectedCollections(collectionsWithCard); 126 - }} 127 title={ 128 <Stack gap={0}> 129 <Text fw={600}>Add or update card</Text> 130 - <Text c={'gray'} fw={500}> 131 {props.isInYourLibrary 132 ? props.urlLibraryCount === 1 133 ? 'Saved by you' ··· 142 centered 143 onClick={(e) => e.stopPropagation()} 144 > 145 - <Stack justify="space-between"> 146 - <CardToBeAddedPreview 147 - cardContent={props.cardContent} 148 - note={isMyCard ? note : undefined} 149 - onUpdateNote={setNote} 150 - /> 151 - 152 <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 - /> 164 </Suspense> 165 - </Stack> 166 </Modal> 167 ); 168 }
··· 1 import type { UrlCard } from '@/api-client'; 2 import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 3 import { Modal, Stack, Text } from '@mantine/core'; 4 + import { Suspense } from 'react'; 5 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 6 + import AddCardToModalContent from './AddCardToModalContent'; // new file or inline 7 8 interface Props { 9 isOpen: boolean; ··· 16 } 17 18 export default function AddCardToModal(props: Props) { 19 return ( 20 <Modal 21 opened={props.isOpen} 22 + onClose={props.onClose} 23 title={ 24 <Stack gap={0}> 25 <Text fw={600}>Add or update card</Text> 26 + <Text c="gray" fw={500}> 27 {props.isInYourLibrary 28 ? props.urlLibraryCount === 1 29 ? 'Saved by you' ··· 38 centered 39 onClick={(e) => e.stopPropagation()} 40 > 41 + {props.isOpen && ( 42 <Suspense fallback={<CollectionSelectorSkeleton />}> 43 + <AddCardToModalContent {...props} /> 44 </Suspense> 45 + )} 46 </Modal> 47 ); 48 }
+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
··· 8 Group, 9 Anchor, 10 AspectRatio, 11 - Skeleton, 12 Tooltip, 13 } from '@mantine/core'; 14 import Link from 'next/link'; 15 import UrlCardActions from '../urlCardActions/UrlCardActions'; 16 - import { MouseEvent, Suspense } from 'react'; 17 import { useRouter } from 'next/navigation'; 18 import styles from './UrlCard.module.css'; 19 ··· 23 url: string; 24 cardContent: UrlCard['cardContent']; 25 note?: UrlCard['note']; 26 - collections?: Collection[]; 27 currentCollection?: Collection; 28 urlLibraryCount: number; 29 urlIsInLibrary?: boolean; ··· 97 )} 98 </Group> 99 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> 122 </Stack> 123 </Card> 124 );
··· 8 Group, 9 Anchor, 10 AspectRatio, 11 Tooltip, 12 } from '@mantine/core'; 13 import Link from 'next/link'; 14 import UrlCardActions from '../urlCardActions/UrlCardActions'; 15 + import { MouseEvent } from 'react'; 16 import { useRouter } from 'next/navigation'; 17 import styles from './UrlCard.module.css'; 18 ··· 22 url: string; 23 cardContent: UrlCard['cardContent']; 24 note?: UrlCard['note']; 25 currentCollection?: Collection; 26 urlLibraryCount: number; 27 urlIsInLibrary?: boolean; ··· 95 )} 96 </Group> 97 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 + /> 109 </Stack> 110 </Card> 111 );
+1 -1
src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx
··· 70 url={card.url} 71 cardContent={card.cardContent} 72 note={card.note} 73 - collections={card.collections} 74 authorHandle={props.handle} 75 urlLibraryCount={card.urlLibraryCount} 76 urlIsInLibrary={card.urlInLibrary} 77 />
··· 70 url={card.url} 71 cardContent={card.cardContent} 72 note={card.note} 73 authorHandle={props.handle} 74 + cardAuthor={card.author} 75 urlLibraryCount={card.urlLibraryCount} 76 urlIsInLibrary={card.urlInLibrary} 77 />
+1
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
··· 116 url={card.url} 117 cardContent={card.cardContent} 118 authorHandle={firstPage.author.handle} 119 note={card.note} 120 urlLibraryCount={card.urlLibraryCount} 121 urlIsInLibrary={card.urlInLibrary}
··· 116 url={card.url} 117 cardContent={card.cardContent} 118 authorHandle={firstPage.author.handle} 119 + cardAuthor={firstPage.author} 120 note={card.note} 121 urlLibraryCount={card.urlLibraryCount} 122 urlIsInLibrary={card.urlInLibrary}
-8
src/webapp/features/collections/lib/queries/useMyCollections.tsx
··· 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 import { getMyCollections } from '../dal'; 3 - import { useAuth } from '@/hooks/useAuth'; 4 5 interface Props { 6 limit?: number; 7 } 8 9 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 const limit = props?.limit ?? 15; 18 19 return useSuspenseInfiniteQuery({
··· 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 import { getMyCollections } from '../dal'; 3 4 interface Props { 5 limit?: number; 6 } 7 8 export default function useMyCollections(props?: Props) { 9 const limit = props?.limit ?? 15; 10 11 return useSuspenseInfiniteQuery({
+1 -1
src/webapp/features/home/containers/homeContainer/HomeContainer.tsx
··· 120 url={card.url} 121 cardContent={card.cardContent} 122 note={card.note} 123 - collections={card.collections} 124 urlLibraryCount={card.urlLibraryCount} 125 urlIsInLibrary={card.urlInLibrary} 126 /> 127 </Grid.Col> 128 ))}
··· 120 url={card.url} 121 cardContent={card.cardContent} 122 note={card.note} 123 urlLibraryCount={card.urlLibraryCount} 124 urlIsInLibrary={card.urlInLibrary} 125 + cardAuthor={card.author} 126 /> 127 </Grid.Col> 128 ))}
+24 -28
src/webapp/features/notes/components/noteCardModal/NoteCardModal.tsx
··· 30 <Modal 31 opened={props.isOpen} 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 - } 60 overlayProps={UPDATE_OVERLAY_PROPS} 61 centered 62 onClick={(e) => e.stopPropagation()} 63 > 64 - <Stack gap={'xs'} mt={'xs'}> 65 {props.note && <Text fs={'italic'}>{props.note.text}</Text>} 66 <Card withBorder p={'xs'} radius={'lg'}> 67 <Stack>
··· 30 <Modal 31 opened={props.isOpen} 32 onClose={props.onClose} 33 + title="Note" 34 overlayProps={UPDATE_OVERLAY_PROPS} 35 centered 36 onClick={(e) => e.stopPropagation()} 37 > 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 + )} 61 {props.note && <Text fs={'italic'}>{props.note.text}</Text>} 62 <Card withBorder p={'xs'} radius={'lg'}> 63 <Stack>
-1
src/webapp/features/profile/containers/profileContainer/ProfileContainer.tsx
··· 76 cardAuthor={card.author} 77 cardContent={card.cardContent} 78 note={card.note} 79 - collections={card.collections} 80 authorHandle={props.handle} 81 urlLibraryCount={card.urlLibraryCount} 82 urlIsInLibrary={card.urlInLibrary}
··· 76 cardAuthor={card.author} 77 cardContent={card.cardContent} 78 note={card.note} 79 authorHandle={props.handle} 80 urlLibraryCount={card.urlLibraryCount} 81 urlIsInLibrary={card.urlInLibrary}
-8
src/webapp/features/profile/lib/queries/useMyProfile.tsx
··· 1 import { useSuspenseQuery, useQuery } from '@tanstack/react-query'; 2 import { getMyProfile } from '../dal'; 3 - import { useAuth } from '@/hooks/useAuth'; 4 5 export default function useMyProfile() { 6 - const { isAuthenticated } = useAuth(); 7 - 8 - if (!isAuthenticated) { 9 - // don't trigger Suspense 10 - return { data: null }; 11 - } 12 - 13 return useSuspenseQuery({ 14 queryKey: ['my profile'], 15 queryFn: () => getMyProfile(),
··· 1 import { useSuspenseQuery, useQuery } from '@tanstack/react-query'; 2 import { getMyProfile } from '../dal'; 3 4 export default function useMyProfile() { 5 return useSuspenseQuery({ 6 queryKey: ['my profile'], 7 queryFn: () => getMyProfile(),
+5
src/webapp/features/semble/containers/sembleContainer/Error.SembleContainer.tsx
···
··· 1 + import { Alert } from '@mantine/core'; 2 + 3 + export default function SembleContainerError() { 4 + return <Alert color="red" title="Could not load semble page" />; 5 + }
+41 -107
src/webapp/hooks/useAuth.tsx
··· 1 'use client'; 2 3 - import { 4 - useState, 5 - useEffect, 6 - createContext, 7 - useContext, 8 - ReactNode, 9 - useCallback, 10 - } from 'react'; 11 import { useRouter } from 'next/navigation'; 12 - import { ClientCookieAuthService } from '@/services/auth'; 13 - import { ApiClient } from '@/api-client/ApiClient'; 14 import type { GetProfileResponse } from '@/api-client/ApiClient'; 15 16 type UserProfile = GetProfileResponse; 17 18 - interface AuthState { 19 isAuthenticated: boolean; 20 isLoading: boolean; 21 - user: UserProfile | null; 22 - } 23 - 24 - interface AuthContextType extends AuthState { 25 - login: (handle: string) => Promise<{ authUrl: string }>; 26 logout: () => Promise<void>; 27 - refreshAuth: () => Promise<boolean>; 28 } 29 30 const AuthContext = createContext<AuthContextType | undefined>(undefined); 31 32 export const AuthProvider = ({ children }: { children: ReactNode }) => { 33 - const [authState, setAuthState] = useState<AuthState>({ 34 - isAuthenticated: false, 35 - isLoading: true, 36 - user: null, 37 - }); 38 - 39 const router = useRouter(); 40 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' 46 const response = await fetch('/api/auth/me', { 47 method: 'GET', 48 - credentials: 'include', 49 }); 50 51 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; 60 } 61 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 - }, []); 83 84 - // Initialize auth on mount 85 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 - }, []); 109 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]); 124 125 return ( 126 - <AuthContext.Provider 127 - value={{ 128 - ...authState, 129 - login, 130 - logout, 131 - refreshAuth, 132 - }} 133 - > 134 - {children} 135 - </AuthContext.Provider> 136 ); 137 }; 138 139 - export const useAuth = () => { 140 const context = useContext(AuthContext); 141 if (!context) { 142 throw new Error('useAuth must be used within an AuthProvider');
··· 1 'use client'; 2 3 + import { createContext, useContext, ReactNode, useEffect } from 'react'; 4 + import { useQuery, useQueryClient } from '@tanstack/react-query'; 5 import { useRouter } from 'next/navigation'; 6 import type { GetProfileResponse } from '@/api-client/ApiClient'; 7 + import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client'; 8 9 type UserProfile = GetProfileResponse; 10 11 + interface AuthContextType { 12 + user: UserProfile | null; 13 isAuthenticated: boolean; 14 isLoading: boolean; 15 + refreshAuth: () => Promise<void>; 16 logout: () => Promise<void>; 17 } 18 19 const AuthContext = createContext<AuthContextType | undefined>(undefined); 20 21 export const AuthProvider = ({ children }: { children: ReactNode }) => { 22 const router = useRouter(); 23 + const queryClient = useQueryClient(); 24 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 () => { 38 const response = await fetch('/api/auth/me', { 39 method: 'GET', 40 + credentials: 'include', // HttpOnly cookies sent automatically 41 }); 42 43 + // unauthenticated 44 if (!response.ok) { 45 + throw new Error('Not authenticated'); 46 } 47 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 + }); 55 56 useEffect(() => { 57 + if (query.isError) logout(); 58 + }, [query.isError, logout]); 59 60 + const contextValue: AuthContextType = { 61 + user: query.data ?? null, 62 + isAuthenticated: !!query.data, 63 + isLoading: query.isLoading, 64 + refreshAuth, 65 + logout, 66 + }; 67 68 return ( 69 + <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider> 70 ); 71 }; 72 73 + export const useAuth = (): AuthContextType => { 74 const context = useContext(AuthContext); 75 if (!context) { 76 throw new Error('useAuth must be used within an AuthProvider');