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 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
··· 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
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 172 172 getMyUrlCardsUseCase: new GetUrlCardsUseCase( 173 173 repositories.cardQueryRepository, 174 174 services.identityResolutionService, 175 + services.profileService, 175 176 ), 176 177 createCollectionUseCase: new CreateCollectionUseCase( 177 178 repositories.collectionRepository,
+1 -1
src/types/src/api/responses.ts
··· 128 128 export interface GetProfileResponse extends User {} 129 129 130 130 export interface GetUrlCardsResponse { 131 - cards: UrlCardWithCollections[]; 131 + cards: UrlCard[]; 132 132 pagination: Pagination; 133 133 sorting: CardSorting; 134 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 48 if (!url) { 49 49 redirect('/'); 50 50 } 51 + 51 52 52 53 return ( 53 54 <Fragment>
+1 -2
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
··· 1 1 import { 2 - Button, 3 - Center, 2 + Button, 4 3 Container, 5 4 Drawer, 6 5 Group,
+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
··· 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 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
··· 70 70 url={card.url} 71 71 cardContent={card.cardContent} 72 72 note={card.note} 73 - collections={card.collections} 74 73 authorHandle={props.handle} 74 + cardAuthor={card.author} 75 75 urlLibraryCount={card.urlLibraryCount} 76 76 urlIsInLibrary={card.urlInLibrary} 77 77 />
+1
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
··· 116 116 url={card.url} 117 117 cardContent={card.cardContent} 118 118 authorHandle={firstPage.author.handle} 119 + cardAuthor={firstPage.author} 119 120 note={card.note} 120 121 urlLibraryCount={card.urlLibraryCount} 121 122 urlIsInLibrary={card.urlInLibrary}
-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
··· 120 120 url={card.url} 121 121 cardContent={card.cardContent} 122 122 note={card.note} 123 - collections={card.collections} 124 123 urlLibraryCount={card.urlLibraryCount} 125 124 urlIsInLibrary={card.urlInLibrary} 125 + cardAuthor={card.author} 126 126 /> 127 127 </Grid.Col> 128 128 ))}
+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
··· 76 76 cardAuthor={card.author} 77 77 cardContent={card.cardContent} 78 78 note={card.note} 79 - collections={card.collections} 80 79 authorHandle={props.handle} 81 80 urlLibraryCount={card.urlLibraryCount} 82 81 urlIsInLibrary={card.urlInLibrary}
-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
··· 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 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');