diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts index ec3f867d..d4967ea4 100644 --- a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -11,9 +11,6 @@ import { } from '../../../../cards/domain/ICardQueryRepository'; import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; -import { IIdentityResolutionService } from '../../../../atproto/domain/services/IIdentityResolutionService'; -import { DID } from '../../../../atproto/domain/DID'; -import { DIDOrHandle } from '../../../../atproto/domain/DIDOrHandle'; import { GetGlobalFeedResponse, FeedItem } from '@semble/types'; export interface GetGlobalFeedQuery { @@ -44,7 +41,6 @@ export class GetGlobalFeedUseCase private profileService: IProfileService, private cardQueryRepository: ICardQueryRepository, private collectionRepository: ICollectionRepository, - private identityResolutionService: IIdentityResolutionService, ) {} async execute( -- 2.51.2 From 5c7a90b2c6671d57f73345ca2e5c3ba0f8adce20 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:42:55 -0700 Subject: [PATCH 02/29] These changes look comprehensive and well-structured. The implementation follows the recommended approach of adding a new `getUrlCardBasic` method that: 1. Fetches only the URL card data 2. Includes a note only if it's by the same author 3. Avoids fetching collections or libraries 4. Provides a lightweight, efficient query The test coverage looks thorough, testing various scenarios like: - Non-existent cards - Non-URL cards - Cards with and without metadata - Cards with notes by the same/different authors A few key improvements: - Simplified the use case by using the new query method - Updated type definitions to return a full `UrlCard` - Maintained the existing pattern of using `CardMapper` for transformations Would you like me to elaborate on any part of the implementation or discuss any potential optimizations? Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.ts | 157 +++++++++----- .../cards/domain/ICardQueryRepository.ts | 5 + .../DrizzleCardQueryRepository.ts | 7 + .../query-services/UrlCardQueryService.ts | 109 ++++++++++ ...sitory.getUrlCardBasic.integration.test.ts | 205 ++++++++++++++++++ src/types/src/api/responses.ts | 2 +- 6 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts diff --git a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts index c230ae73..571cf10a 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts @@ -4,13 +4,14 @@ import { UseCaseError } from '../../../../../shared/core/UseCaseError'; import { AppError } from '../../../../../shared/core/AppError'; import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; import { ICardRepository } from '../../../domain/ICardRepository'; +import { ICardQueryRepository } from '../../../domain/ICardQueryRepository'; import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; import { ICollectionRepository } from '../../../domain/ICollectionRepository'; import { IProfileService } from '../../../domain/services/IProfileService'; import { CuratorId } from '../../../domain/value-objects/CuratorId'; import { URL } from '../../../domain/value-objects/URL'; import { CollectionId } from '../../../domain/value-objects/CollectionId'; -import { CollectionDTO } from '@semble/types'; +import { CollectionDTO, UrlCard } from '@semble/types'; export interface GetUrlStatusForMyLibraryQuery { url: string; @@ -18,7 +19,7 @@ export interface GetUrlStatusForMyLibraryQuery { } export interface GetUrlStatusForMyLibraryResult { - cardId?: string; + card?: UrlCard; collections?: CollectionDTO[]; } @@ -37,6 +38,7 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< > { constructor( private cardRepository: ICardRepository, + private cardQueryRepository: ICardQueryRepository, private collectionQueryRepository: ICollectionQueryRepository, private collectionRepo: ICollectionRepository, private profileService: IProfileService, @@ -85,65 +87,104 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< const result: GetUrlStatusForMyLibraryResult = {}; if (card) { - result.cardId = card.cardId.getStringValue(); - - // Get collections containing this card for the user - try { - const collections = - await this.collectionQueryRepository.getCollectionsContainingCardForUser( - card.cardId.getStringValue(), - curatorId.value, - ); + // Get enriched card data with note + const cardView = await this.cardQueryRepository.getUrlCardBasic( + card.cardId.getStringValue(), + curatorId.value, + ); - // Enrich collections with full data - result.collections = await Promise.all( - collections.map(async (collection): Promise => { - // Fetch full collection to get dates and cardCount - const collectionIdResult = CollectionId.createFromString( - collection.id, - ); - if (collectionIdResult.isErr()) { - throw new Error(`Invalid collection ID: ${collection.id}`); - } - const collectionResult = await this.collectionRepo.findById( - collectionIdResult.value, - ); - if (collectionResult.isErr() || !collectionResult.value) { - throw new Error(`Collection not found: ${collection.id}`); - } - const fullCollection = collectionResult.value; - - // Fetch author profile - const authorProfileResult = await this.profileService.getProfile( - fullCollection.authorId.value, + if (cardView) { + // Get card author profile + const authorProfileResult = await this.profileService.getProfile( + cardView.authorId, + curatorId.value, + ); + + if (authorProfileResult.isErr()) { + return err(AppError.UnexpectedError.create(authorProfileResult.error)); + } + + const authorProfile = authorProfileResult.value; + + // Transform to UrlCard format + result.card = { + id: cardView.id, + type: 'URL', + url: cardView.url, + cardContent: cardView.cardContent, + libraryCount: cardView.libraryCount, + urlLibraryCount: cardView.urlLibraryCount, + urlInLibrary: cardView.urlInLibrary, + createdAt: cardView.createdAt.toISOString(), + updatedAt: cardView.updatedAt.toISOString(), + author: { + id: authorProfile.id, + name: authorProfile.name, + handle: authorProfile.handle, + avatarUrl: authorProfile.avatarUrl, + description: authorProfile.bio, + }, + note: cardView.note, // This includes the note if it exists + }; + + // Get collections containing this card for the user + try { + const collections = + await this.collectionQueryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, ); - if (authorProfileResult.isErr()) { - throw new Error( - `Failed to fetch author profile: ${authorProfileResult.error.message}`, + + // Enrich collections with full data + result.collections = await Promise.all( + collections.map(async (collection): Promise => { + // Fetch full collection to get dates and cardCount + const collectionIdResult = CollectionId.createFromString( + collection.id, ); - } - const authorProfile = authorProfileResult.value; - - return { - id: collection.id, - uri: collection.uri, - name: collection.name, - description: collection.description, - author: { - id: authorProfile.id, - name: authorProfile.name, - handle: authorProfile.handle, - avatarUrl: authorProfile.avatarUrl, - description: authorProfile.bio, - }, - cardCount: fullCollection.cardCount, - createdAt: fullCollection.createdAt.toISOString(), - updatedAt: fullCollection.updatedAt.toISOString(), - }; - }), - ); - } catch (error) { - return err(AppError.UnexpectedError.create(error)); + if (collectionIdResult.isErr()) { + throw new Error(`Invalid collection ID: ${collection.id}`); + } + const collectionResult = await this.collectionRepo.findById( + collectionIdResult.value, + ); + if (collectionResult.isErr() || !collectionResult.value) { + throw new Error(`Collection not found: ${collection.id}`); + } + const fullCollection = collectionResult.value; + + // Fetch author profile + const authorProfileResult = await this.profileService.getProfile( + fullCollection.authorId.value, + ); + if (authorProfileResult.isErr()) { + throw new Error( + `Failed to fetch author profile: ${authorProfileResult.error.message}`, + ); + } + const authorProfile = authorProfileResult.value; + + return { + id: collection.id, + uri: collection.uri, + name: collection.name, + description: collection.description, + author: { + id: authorProfile.id, + name: authorProfile.name, + handle: authorProfile.handle, + avatarUrl: authorProfile.avatarUrl, + description: authorProfile.bio, + }, + cardCount: fullCollection.cardCount, + createdAt: fullCollection.createdAt.toISOString(), + updatedAt: fullCollection.updatedAt.toISOString(), + }; + }), + ); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } } } diff --git a/src/modules/cards/domain/ICardQueryRepository.ts b/src/modules/cards/domain/ICardQueryRepository.ts index e66b6734..31ea0536 100644 --- a/src/modules/cards/domain/ICardQueryRepository.ts +++ b/src/modules/cards/domain/ICardQueryRepository.ts @@ -129,6 +129,11 @@ export interface ICardQueryRepository { callingUserId?: string, ): Promise; + getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise; + getLibrariesForCard(cardId: string): Promise; getLibrariesForUrl( diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts index 19c7f75c..4c75b860 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts @@ -58,6 +58,13 @@ export class DrizzleCardQueryRepository implements ICardQueryRepository { return this.urlCardQueryService.getUrlCardView(cardId, callingUserId); } + async getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise { + return this.urlCardQueryService.getUrlCardBasic(cardId, callingUserId); + } + async getLibrariesForCard(cardId: string): Promise { return this.libraryQueryService.getLibrariesForCard(cardId); } diff --git a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts index b8f800d2..eeb655d9 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts @@ -471,6 +471,115 @@ export class UrlCardQueryService { } } + async getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise { + try { + // Get the URL card + const cardQuery = this.db + .select({ + id: cards.id, + type: cards.type, + authorId: cards.authorId, + url: cards.url, + contentData: cards.contentData, + libraryCount: cards.libraryCount, + createdAt: cards.createdAt, + updatedAt: cards.updatedAt, + }) + .from(cards) + .where(and(eq(cards.id, cardId), eq(cards.type, CardTypeEnum.URL))); + + const cardResult = await cardQuery; + + if (cardResult.length === 0) { + return null; + } + + const card = cardResult[0]!; + + // Get note card for this URL card (same user, parentCardId matches, type = NOTE) + const noteQuery = this.db + .select({ + id: cards.id, + parentCardId: cards.parentCardId, + contentData: cards.contentData, + }) + .from(cards) + .where( + and( + eq(cards.type, CardTypeEnum.NOTE), + eq(cards.parentCardId, cardId), + eq(cards.authorId, card.authorId), // Only notes by the same author + ), + ) + .limit(1); // Only get the first note if multiple exist + + const noteResult = await noteQuery; + const note = noteResult.length > 0 ? noteResult[0] : undefined; + + // Get urlLibraryCount for this URL (count of unique users who have cards with this URL) + const urlLibraryCountQuery = this.db + .select({ + count: countDistinct(libraryMemberships.userId), + }) + .from(cards) + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) + .where(and(eq(cards.type, CardTypeEnum.URL), eq(cards.url, card.url))); + + const urlLibraryCountResult = await urlLibraryCountQuery; + const urlLibraryCount = urlLibraryCountResult[0]?.count || 0; + + // Get urlInLibrary if callingUserId is provided + let urlInLibrary: boolean | undefined; + if (callingUserId) { + // Check if the calling user has any card with this URL + const urlInLibraryQuery = this.db + .select({ + id: cards.id, + }) + .from(cards) + .where( + and( + eq(cards.authorId, callingUserId), + eq(cards.type, CardTypeEnum.URL), + eq(cards.url, card.url), + ), + ) + .limit(1); + + const urlInLibraryResult = await urlInLibraryQuery; + urlInLibrary = urlInLibraryResult.length > 0; + } + + // Create raw card data for mapping + const rawCardData = { + id: card.id, + authorId: card.authorId, + url: card.url || '', + contentData: card.contentData, + libraryCount: card.libraryCount, + urlLibraryCount, + urlInLibrary, + createdAt: card.createdAt, + updatedAt: card.updatedAt, + note: note + ? { + id: note.id, + contentData: note.contentData, + } + : undefined, + }; + + // Use CardMapper to transform to UrlCardView (without collections) + return CardMapper.toCollectionCardQueryResult(rawCardData); + } catch (error) { + console.error('Error in getUrlCardBasic:', error); + throw error; + } + } + private getSortColumn(sortBy: CardSortField) { switch (sortBy) { case CardSortField.CREATED_AT: diff --git a/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts new file mode 100644 index 00000000..005adb80 --- /dev/null +++ b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts @@ -0,0 +1,205 @@ +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql'; +import postgres from 'postgres'; +import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { DrizzleCardQueryRepository } from '../../infrastructure/repositories/DrizzleCardQueryRepository'; +import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; +import { cards } from '../../infrastructure/repositories/schema/card.sql'; +import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; +import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; +import { CardBuilder } from '../utils/builders/CardBuilder'; +import { URL } from '../../domain/value-objects/URL'; +import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; +import { createTestSchema } from '../test-utils/createTestSchema'; +import { CardTypeEnum } from '../../domain/value-objects/CardType'; + +describe('DrizzleCardQueryRepository - getUrlCardBasic', () => { + let container: StartedPostgreSqlContainer; + let db: PostgresJsDatabase; + let queryRepository: DrizzleCardQueryRepository; + let cardRepository: DrizzleCardRepository; + + // Test data + let curatorId: CuratorId; + let otherCuratorId: CuratorId; + + // Setup before all tests + beforeAll(async () => { + // Start PostgreSQL container + container = await new PostgreSqlContainer('postgres:14').start(); + + // Create database connection + const connectionString = container.getConnectionUri(); + process.env.DATABASE_URL = connectionString; + const client = postgres(connectionString); + db = drizzle(client); + + // Create repositories + queryRepository = new DrizzleCardQueryRepository(db); + cardRepository = new DrizzleCardRepository(db); + + // Create schema using helper function + await createTestSchema(db); + + // Create test data + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); + otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); + }, 60000); // Increase timeout for container startup + + // Cleanup after all tests + afterAll(async () => { + // Stop container + await container.stop(); + }); + + // Clear data between tests + beforeEach(async () => { + await db.delete(libraryMemberships); + await db.delete(cards); + await db.delete(publishedRecords); + }); + + describe('getUrlCardBasic', () => { + it('should return null when card does not exist', async () => { + const nonExistentCardId = new UniqueEntityID().toString(); + + const result = await queryRepository.getUrlCardBasic(nonExistentCardId); + + expect(result).toBeNull(); + }); + + it('should return null when card exists but is not a URL card', async () => { + // Create a note card + const noteCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withNoteCard('This is a note') + .buildOrThrow(); + + await cardRepository.save(noteCard); + + const result = await queryRepository.getUrlCardBasic( + noteCard.cardId.getStringValue(), + ); + + expect(result).toBeNull(); + }); + + it('should return URL card with basic metadata', async () => { + // Create URL card with metadata + const url = URL.create('https://example.com/article').unwrap(); + const urlMetadata = UrlMetadata.create({ + url: url.value, + title: 'Test Article', + description: 'A test article description', + author: 'John Doe', + imageUrl: 'https://example.com/image.jpg', + }).unwrap(); + + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url, urlMetadata) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.id).toBe(urlCard.cardId.getStringValue()); + expect(result?.type).toBe(CardTypeEnum.URL); + expect(result?.url).toBe(url.value); + expect(result?.cardContent.title).toBe('Test Article'); + expect(result?.cardContent.description).toBe('A test article description'); + expect(result?.cardContent.author).toBe('John Doe'); + expect(result?.cardContent.thumbnailUrl).toBe('https://example.com/image.jpg'); + expect(result?.note).toBeUndefined(); + }); + + it('should include connected note card by the same author', async () => { + // Create URL card + const url = URL.create('https://example.com/article-with-note').unwrap(); + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + // Create connected note card by the same author + const noteCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withNoteCard('This is my note about the article') + .withParentCard(urlCard.cardId) + .buildOrThrow(); + + await cardRepository.save(noteCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.note).toBeDefined(); + expect(result?.note?.id).toBe(noteCard.cardId.getStringValue()); + expect(result?.note?.text).toBe('This is my note about the article'); + }); + + it('should NOT include note card by a different author', async () => { + // Create URL card by first author + const url = URL.create('https://example.com/article-different-author').unwrap(); + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + // Create connected note card by a DIFFERENT author + const noteCard = new CardBuilder() + .withCuratorId(otherCuratorId.value) // Different author + .withNoteCard('This is someone elses note') + .withParentCard(urlCard.cardId) + .buildOrThrow(); + + await cardRepository.save(noteCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.note).toBeUndefined(); // Should not include note from different author + }); + + it('should handle URL card without metadata', async () => { + // Create URL card without metadata + const url = URL.create('https://example.com/minimal').unwrap(); + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.id).toBe(urlCard.cardId.getStringValue()); + expect(result?.type).toBe(CardTypeEnum.URL); + expect(result?.url).toBe(url.value); + expect(result?.cardContent.title).toBeUndefined(); + expect(result?.cardContent.description).toBeUndefined(); + expect(result?.cardContent.author).toBeUndefined(); + expect(result?.cardContent.thumbnailUrl).toBeUndefined(); + expect(result?.note).toBeUndefined(); + }); + }); +}); diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 905f32ad..8eb4276b 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -192,7 +192,7 @@ export interface GetGlobalFeedResponse { } export interface GetUrlStatusForMyLibraryResponse { - cardId?: string; + card?: UrlCard; collections?: Collection[]; } -- 2.51.2 From 7bcd0054f0e0486f370e3cd54aff591810823591 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:44:47 -0700 Subject: [PATCH 03/29] feat: implement getUrlCardBasic method in card query repository Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../query-services/UrlCardQueryService.ts | 1 + .../utils/InMemoryCardQueryRepository.ts | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts index eeb655d9..807378b5 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts @@ -1,4 +1,5 @@ import { eq, desc, asc, count, countDistinct, inArray, and } from 'drizzle-orm'; +import { UrlCardView } from '../../../domain/ICardQueryRepository'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { CardQueryOptions, diff --git a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts index c7c65c07..e8948b24 100644 --- a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts @@ -4,6 +4,7 @@ import { UrlCardQueryResultDTO, CollectionCardQueryResultDTO, UrlCardViewDTO, + UrlCardView, PaginatedQueryResult, CardSortField, SortOrder, @@ -349,6 +350,62 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { }; } + async getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise { + const allCards = this.cardRepository.getAllCards(); + const card = allCards.find((c) => c.cardId.getStringValue() === cardId); + if (!card || !card.isUrlCard) { + return null; + } + + // Find note card by the same author with matching parent card ID + const noteCard = allCards.find( + (c) => + c.type.value === 'NOTE' && + c.parentCardId?.equals(card.cardId) && + c.curatorId.value === card.curatorId.value, // Only notes by the same author + ); + + const note = noteCard + ? { + id: noteCard.cardId.getStringValue(), + text: noteCard.content.noteContent?.text || '', + } + : undefined; + + // Compute urlInLibrary if callingUserId is provided + const urlInLibrary = callingUserId + ? this.isUrlInUserLibrary( + card.content.urlContent!.url.value, + callingUserId, + ) + : undefined; + + return { + id: card.cardId.getStringValue(), + type: CardTypeEnum.URL, + url: card.content.urlContent!.url.value, + cardContent: { + url: card.content.urlContent!.url.value, + title: card.content.urlContent!.metadata?.title, + description: card.content.urlContent!.metadata?.description, + author: card.content.urlContent!.metadata?.author, + thumbnailUrl: card.content.urlContent!.metadata?.imageUrl, + }, + libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()), + urlLibraryCount: this.getUrlLibraryCount( + card.content.urlContent!.url.value, + ), + urlInLibrary, + createdAt: card.createdAt, + updatedAt: card.updatedAt, + authorId: card.curatorId.value, + note, + }; + } + async getLibrariesForCard(cardId: string): Promise { const allCards = this.cardRepository.getAllCards(); const card = allCards.find((c) => c.cardId.getStringValue() === cardId); -- 2.51.2 From efa8fa3bbd7184ae93b08093dd11426211f493af Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:45:52 -0700 Subject: [PATCH 04/29] test: update GetUrlStatusForMyLibraryUseCase tests with new constructor params Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index 01932ed3..5618f5c6 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -1,5 +1,6 @@ import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; +import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository'; import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; import { FakeCardPublisher } from '../utils/FakeCardPublisher'; @@ -18,6 +19,7 @@ import { ICardRepository } from '../../domain/ICardRepository'; describe('GetUrlStatusForMyLibraryUseCase', () => { let useCase: GetUrlStatusForMyLibraryUseCase; let cardRepository: InMemoryCardRepository; + let cardQueryRepository: InMemoryCardQueryRepository; let collectionRepository: InMemoryCollectionRepository; let collectionQueryRepository: InMemoryCollectionQueryRepository; let cardPublisher: FakeCardPublisher; @@ -30,6 +32,10 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { beforeEach(() => { cardRepository = InMemoryCardRepository.getInstance(); collectionRepository = InMemoryCollectionRepository.getInstance(); + cardQueryRepository = new InMemoryCardQueryRepository( + cardRepository, + collectionRepository, + ); collectionQueryRepository = new InMemoryCollectionQueryRepository( collectionRepository, ); @@ -40,6 +46,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { useCase = new GetUrlStatusForMyLibraryUseCase( cardRepository, + cardQueryRepository, collectionQueryRepository, collectionRepository, profileService, @@ -69,6 +76,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { afterEach(() => { cardRepository.clear(); + cardQueryRepository.clear(); collectionRepository.clear(); collectionQueryRepository.clear(); cardPublisher.clear(); @@ -527,6 +535,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const errorUseCase = new GetUrlStatusForMyLibraryUseCase( errorCardRepository, + cardQueryRepository, collectionQueryRepository, collectionRepository, profileService, @@ -581,6 +590,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const errorUseCase = new GetUrlStatusForMyLibraryUseCase( cardRepository, + cardQueryRepository, errorCollectionQueryRepository, collectionRepository, profileService, -- 2.51.2 From d5c091ddcdb8852a6f21b309740129ed650715ce Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:47:23 -0700 Subject: [PATCH 05/29] test: update tests to use new card property in GetUrlStatusForMyLibraryUseCase Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index 5618f5c6..008c5ed1 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -221,7 +221,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card.cardId.getStringValue()); expect(response.collections).toHaveLength(2); // Verify collection details @@ -297,7 +298,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card.cardId.getStringValue()); expect(response.collections).toHaveLength(0); }); @@ -316,7 +318,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBeUndefined(); + expect(response.card).toBeUndefined(); expect(response.collections).toBeUndefined(); }); @@ -400,7 +402,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card1.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card1.cardId.getStringValue()); expect(response.collections).toHaveLength(0); // No collections for first user }); @@ -481,7 +484,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card.cardId.getStringValue()); expect(response.collections).toHaveLength(1); expect(response.collections?.[0]?.name).toBe('My Collection'); expect(response.collections?.[0]?.id).toBe( -- 2.51.2 From 98147f8154e80ff95d65b198d7e50dfc2041b130 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:48:52 -0700 Subject: [PATCH 06/29] fix: add missing `getUrlCardBasic` method to mock repositories and correct DTO type Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../infrastructure/repositories/DrizzleCardQueryRepository.ts | 2 +- .../cards/tests/application/GetCollectionPageUseCase.test.ts | 1 + .../cards/tests/application/GetLibrariesForUrlUseCase.test.ts | 1 + .../cards/tests/application/GetMyUrlCardsUseCase.test.ts | 1 + .../cards/tests/application/GetUrlCardViewUseCase.test.ts | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts index 4c75b860..93f89ec4 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts @@ -61,7 +61,7 @@ export class DrizzleCardQueryRepository implements ICardQueryRepository { async getUrlCardBasic( cardId: string, callingUserId?: string, - ): Promise { + ): Promise { return this.urlCardQueryService.getUrlCardBasic(cardId, callingUserId); } diff --git a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts index 6b3fd03d..d91eb734 100644 --- a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts @@ -755,6 +755,7 @@ describe('GetCollectionPageUseCase', () => { .fn() .mockRejectedValue(new Error('Query failed')), getUrlCardView: jest.fn(), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), diff --git a/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts b/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts index e3119bc0..c586ee2f 100644 --- a/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts +++ b/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts @@ -363,6 +363,7 @@ describe('GetLibrariesForUrlUseCase', () => { getUrlCardsOfUser: jest.fn(), getCardsInCollection: jest.fn(), getUrlCardView: jest.fn(), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest .fn() diff --git a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts index aac80b19..a2feed6e 100644 --- a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts @@ -626,6 +626,7 @@ describe('GetUrlCardsUseCase', () => { getUrlCardView: jest .fn() .mockRejectedValue(new Error('Database connection failed')), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), diff --git a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts index 1752c462..de758bc8 100644 --- a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts @@ -478,6 +478,7 @@ describe('GetUrlCardViewUseCase', () => { getUrlCardView: jest .fn() .mockRejectedValue(new Error('Database connection failed')), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), -- 2.51.2 From e628a8398fb624746b6049f711f1563632b2a18d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:50:56 -0700 Subject: [PATCH 07/29] include query repo in use case factory --- src/shared/infrastructure/http/factories/UseCaseFactory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index bd9da335..e68e6ea6 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -199,6 +199,7 @@ export class UseCaseFactory { ), getUrlStatusForMyLibraryUseCase: new GetUrlStatusForMyLibraryUseCase( repositories.cardRepository, + repositories.cardQueryRepository, repositories.collectionQueryRepository, repositories.collectionRepository, services.profileService, @@ -224,7 +225,6 @@ export class UseCaseFactory { services.profileService, repositories.cardQueryRepository, repositories.collectionRepository, - services.identityResolutionService, ), addActivityToFeedUseCase: new AddActivityToFeedUseCase( services.feedService, -- 2.51.2 From 178239ce01882192db4a778aba831f58a39d8cb2 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:51:13 -0700 Subject: [PATCH 08/29] minor type update to repo implementation --- .../infrastructure/repositories/DrizzleCardQueryRepository.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts index 93f89ec4..0c141e2e 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts @@ -8,6 +8,7 @@ import { UrlCardViewDTO, LibraryForUrlDTO, NoteCardForUrlRawDTO, + UrlCardView, } from '../../domain/ICardQueryRepository'; import { UrlCardQueryService } from './query-services/UrlCardQueryService'; import { CollectionCardQueryService } from './query-services/CollectionCardQueryService'; @@ -61,7 +62,7 @@ export class DrizzleCardQueryRepository implements ICardQueryRepository { async getUrlCardBasic( cardId: string, callingUserId?: string, - ): Promise { + ): Promise { return this.urlCardQueryService.getUrlCardBasic(cardId, callingUserId); } -- 2.51.2 From 933673828fb090a33244bcea08d0ee474e51c3b3 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:53:28 -0700 Subject: [PATCH 09/29] formatting and linting --- .../queries/GetUrlStatusForMyLibraryUseCase.ts | 11 +++++++---- ...eryRepository.getUrlCardBasic.integration.test.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts index 571cf10a..b15663f5 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts @@ -101,7 +101,9 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< ); if (authorProfileResult.isErr()) { - return err(AppError.UnexpectedError.create(authorProfileResult.error)); + return err( + AppError.UnexpectedError.create(authorProfileResult.error), + ); } const authorProfile = authorProfileResult.value; @@ -154,9 +156,10 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< const fullCollection = collectionResult.value; // Fetch author profile - const authorProfileResult = await this.profileService.getProfile( - fullCollection.authorId.value, - ); + const authorProfileResult = + await this.profileService.getProfile( + fullCollection.authorId.value, + ); if (authorProfileResult.isErr()) { throw new Error( `Failed to fetch author profile: ${authorProfileResult.error.message}`, diff --git a/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts index 005adb80..04db9477 100644 --- a/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts +++ b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts @@ -115,9 +115,13 @@ describe('DrizzleCardQueryRepository - getUrlCardBasic', () => { expect(result?.type).toBe(CardTypeEnum.URL); expect(result?.url).toBe(url.value); expect(result?.cardContent.title).toBe('Test Article'); - expect(result?.cardContent.description).toBe('A test article description'); + expect(result?.cardContent.description).toBe( + 'A test article description', + ); expect(result?.cardContent.author).toBe('John Doe'); - expect(result?.cardContent.thumbnailUrl).toBe('https://example.com/image.jpg'); + expect(result?.cardContent.thumbnailUrl).toBe( + 'https://example.com/image.jpg', + ); expect(result?.note).toBeUndefined(); }); @@ -152,7 +156,9 @@ describe('DrizzleCardQueryRepository - getUrlCardBasic', () => { it('should NOT include note card by a different author', async () => { // Create URL card by first author - const url = URL.create('https://example.com/article-different-author').unwrap(); + const url = URL.create( + 'https://example.com/article-different-author', + ).unwrap(); const urlCard = new CardBuilder() .withCuratorId(curatorId.value) .withUrlCard(url) -- 2.51.2 From 55a2ff399b5602dd6bf40ab701ff4ff0bc4063c8 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 16:06:13 -0700 Subject: [PATCH 10/29] refactor: update cardId references to card.id and add missing import Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../cards/components/addCardToModal/AddCardToModal.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx index b6fa58f4..4c56331b 100644 --- a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx @@ -11,6 +11,7 @@ import CollectionSelector from '@/features/collections/components/collectionSele import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations'; import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; import useAddCard from '../../lib/mutations/useAddCard'; +import { SelectableCollectionItem } from '@/features/collections/types'; interface Props { isOpen: boolean; @@ -24,7 +25,7 @@ interface Props { export default function AddCardToModal(props: Props) { const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); - const isMyCard = props.cardId === cardStatus.data.cardId; + const isMyCard = props.cardId === cardStatus.data.card?.id; const [note, setNote] = useState(props.note); const { data, error } = useMyCollections(); @@ -58,13 +59,13 @@ export default function AddCardToModal(props: Props) { const hasAdded = addedCollections.length > 0; const hasRemoved = removedCollections.length > 0; - if (cardStatus.data.cardId && !hasNoteChanged && !hasAdded && !hasRemoved) { + if (cardStatus.data.card && !hasNoteChanged && !hasAdded && !hasRemoved) { props.onClose(); return; } // if the card is not in library, add it instead of updating - if (!cardStatus.data.cardId) { + if (!cardStatus.data.card) { addCard.mutate( { url: props.cardContent.url, @@ -91,7 +92,7 @@ export default function AddCardToModal(props: Props) { note?: string; addToCollectionIds?: string[]; removeFromCollectionIds?: string[]; - } = { cardId: cardStatus.data.cardId }; + } = { cardId: cardStatus.data.card!.id }; if (hasNoteChanged) updatedCardPayload.note = trimmedNote; if (hasAdded) -- 2.51.2 From a8531bc18a8e1535ab8bb6e593d93e3573b02479 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 16:10:35 -0700 Subject: [PATCH 11/29] fix build error and unecessary imports --- .../cards/components/addCardToModal/AddCardToModal.tsx | 3 +-- src/webapp/hooks/useUrlMetadata.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx index 4c56331b..f53e8f37 100644 --- a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx @@ -1,6 +1,6 @@ import type { UrlCard } from '@/api-client'; import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; -import { Anchor, Modal, Stack, Text } from '@mantine/core'; +import { Modal, Stack, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { Suspense, useState } from 'react'; import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; @@ -11,7 +11,6 @@ import CollectionSelector from '@/features/collections/components/collectionSele import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations'; import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; import useAddCard from '../../lib/mutations/useAddCard'; -import { SelectableCollectionItem } from '@/features/collections/types'; interface Props { isOpen: boolean; diff --git a/src/webapp/hooks/useUrlMetadata.ts b/src/webapp/hooks/useUrlMetadata.ts index 4d914391..f292cdca 100644 --- a/src/webapp/hooks/useUrlMetadata.ts +++ b/src/webapp/hooks/useUrlMetadata.ts @@ -46,7 +46,7 @@ export function useUrlMetadata({ }); // If there's an existing card, fetch its collections - if (existingCard.cardId) { + if (existingCard.card) { try { setExistingCardCollections(existingCard.collections); } catch (cardErr: any) { -- 2.51.2 From 1833fbc2a150b4837659d38857c1e3c1fd2c1b7f Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Fri, 24 Oct 2025 17:12:26 -0700 Subject: [PATCH 12/29] refactor: infinite scroll loader --- .../contentDisplay/infiniteScroll/InfiniteScroll.tsx | 9 +++++++-- .../cards/containers/cardsContainer/CardsContainer.tsx | 8 +------- .../feeds/containers/myFeedContainer/MyFeedContainer.tsx | 8 +------- .../SembleCollectionsContainer.tsx | 8 +------- .../SembleLibrariesContainer.tsx | 8 +------- .../sembleNotesContainer/SembleNotesContainer.tsx | 8 +------- 6 files changed, 12 insertions(+), 37 deletions(-) diff --git a/src/webapp/components/contentDisplay/infiniteScroll/InfiniteScroll.tsx b/src/webapp/components/contentDisplay/infiniteScroll/InfiniteScroll.tsx index dc52fb7b..13c5856f 100644 --- a/src/webapp/components/contentDisplay/infiniteScroll/InfiniteScroll.tsx +++ b/src/webapp/components/contentDisplay/infiniteScroll/InfiniteScroll.tsx @@ -1,7 +1,7 @@ 'use client'; import { ReactNode, useEffect, startTransition, useRef } from 'react'; -import { Center, Button, Stack, Text } from '@mantine/core'; +import { Center, Button, Stack, Text, Loader } from '@mantine/core'; import { useIntersection } from '@mantine/hooks'; interface Props { @@ -35,7 +35,12 @@ export default function InfiniteScroll(props: Props) { return ( {props.children} - {props.isLoading && props.loader} + {props.isLoading && + (props.loader || ( +
+ +
+ ))}
{!props.isLoading && props.hasMore && props.manualLoadButton && ( diff --git a/src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx b/src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx index d5e49b89..29b73b44 100644 --- a/src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx +++ b/src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Center, Container, Grid, Loader, Stack } from '@mantine/core'; +import { Container, Grid } from '@mantine/core'; import useCards from '../../lib/queries/useCards'; import UrlCard from '@/features/cards/components/urlCard/UrlCard'; import CardsContainerError from './Error.CardsContainer'; @@ -52,12 +52,6 @@ export default function CardsContainer(props: Props) { isInitialLoading={isPending} isLoading={isFetchingNextPage} loadMore={fetchNextPage} - manualLoadButton={false} - loader={ -
- -
- } > {allCards.map((card) => ( diff --git a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx index 48595754..8dc8c622 100644 --- a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx +++ b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx @@ -2,7 +2,7 @@ import useMyFeed from '@/features/feeds/lib/queries/useMyFeed'; import FeedItem from '@/features/feeds/components/feedItem/FeedItem'; -import { Stack, Title, Text, Center, Container, Loader } from '@mantine/core'; +import { Stack, Title, Text, Center, Container } from '@mantine/core'; import MyFeedContainerSkeleton from './Skeleton.MyFeedContainer'; import MyFeedContainerError from './Error.MyFeedContainer'; import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; @@ -46,12 +46,6 @@ export default function MyFeedContainer() { isInitialLoading={isPending} isLoading={isFetchingNextPage} loadMore={fetchNextPage} - manualLoadButton={false} - loader={ -
- -
- } > diff --git a/src/webapp/features/semble/containers/sembleCollectionsContainer/SembleCollectionsContainer.tsx b/src/webapp/features/semble/containers/sembleCollectionsContainer/SembleCollectionsContainer.tsx index a2a958d9..e67802e6 100644 --- a/src/webapp/features/semble/containers/sembleCollectionsContainer/SembleCollectionsContainer.tsx +++ b/src/webapp/features/semble/containers/sembleCollectionsContainer/SembleCollectionsContainer.tsx @@ -2,7 +2,7 @@ import useSembleCollections from '@/features/collections/lib/queries/useSembleCollectionts'; import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; -import { Center, Loader, SimpleGrid } from '@mantine/core'; +import { SimpleGrid } from '@mantine/core'; import SembleCollectionsError from './Error.SembleCollectionsContainer'; import CollectionCard from '@/features/collections/components/collectionCard/CollectionCard'; import SembleEmptyTab from '../../components/sembleEmptyTab/SembleEmptyTab'; @@ -40,12 +40,6 @@ export default function SembleCollectionsContainer(props: Props) { isInitialLoading={isPending} isLoading={isFetchingNextPage} loadMore={fetchNextPage} - manualLoadButton={false} - loader={ -
- -
- } > {allCollections.map((col) => ( diff --git a/src/webapp/features/semble/containers/sembleLibrariesContainer/SembleLibrariesContainer.tsx b/src/webapp/features/semble/containers/sembleLibrariesContainer/SembleLibrariesContainer.tsx index 53fce5d8..1e32c03b 100644 --- a/src/webapp/features/semble/containers/sembleLibrariesContainer/SembleLibrariesContainer.tsx +++ b/src/webapp/features/semble/containers/sembleLibrariesContainer/SembleLibrariesContainer.tsx @@ -2,7 +2,7 @@ import useSembleLibraries from '../../lib/queries/useSembleLibraries'; import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; -import { Center, Divider, Grid, Loader } from '@mantine/core'; +import { Grid } from '@mantine/core'; import SembleLibrariesContainerError from './Error.SembleLibrariesContainer'; import SembleEmptyTab from '../../components/sembleEmptyTab/SembleEmptyTab'; import { LuLibrary } from 'react-icons/lu'; @@ -45,12 +45,6 @@ export default function SembleLibrariesContainer(props: Props) { isInitialLoading={isPending} isLoading={isFetchingNextPage} loadMore={fetchNextPage} - manualLoadButton={false} - loader={ -
- -
- } > {allLibraries.map((item, i) => ( diff --git a/src/webapp/features/semble/containers/sembleNotesContainer/SembleNotesContainer.tsx b/src/webapp/features/semble/containers/sembleNotesContainer/SembleNotesContainer.tsx index 9bbefade..7597030f 100644 --- a/src/webapp/features/semble/containers/sembleNotesContainer/SembleNotesContainer.tsx +++ b/src/webapp/features/semble/containers/sembleNotesContainer/SembleNotesContainer.tsx @@ -2,7 +2,7 @@ import useSembleNotes from '@/features/notes/lib/queries/useSembleNotes'; import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; -import { Center, Grid, Loader } from '@mantine/core'; +import { Grid } from '@mantine/core'; import SembleNotesContainerError from './Error.SembleNotesContainer'; import NoteCard from '@/features/notes/components/noteCard/NoteCard'; import SembleEmptyTab from '../../components/sembleEmptyTab/SembleEmptyTab'; @@ -42,12 +42,6 @@ export default function SembleNotesContainer(props: Props) { isInitialLoading={isPending} isLoading={isFetchingNextPage} loadMore={fetchNextPage} - manualLoadButton={false} - loader={ -
- -
- } > {allNotes.map((note) => ( -- 2.51.2 From a6d72b60147e8588e6eced051f9cdba422b98f91 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Fri, 24 Oct 2025 17:21:36 -0700 Subject: [PATCH 13/29] feat: autofocus on delete button for collection edit modal --- .../components/deleteCollectionModal/DeleteCollectionModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webapp/features/collections/components/deleteCollectionModal/DeleteCollectionModal.tsx b/src/webapp/features/collections/components/deleteCollectionModal/DeleteCollectionModal.tsx index f9f4f87b..9d007d76 100644 --- a/src/webapp/features/collections/components/deleteCollectionModal/DeleteCollectionModal.tsx +++ b/src/webapp/features/collections/components/deleteCollectionModal/DeleteCollectionModal.tsx @@ -51,6 +51,7 @@ export default function DeleteCollectionModal(props: Props) { size="md" onClick={handleDeleteCollection} loading={deleteCollection.isPending} + data-autofocus > Delete -- 2.51.2 From b678b5eb32acb8bca0c5e7ca609fa8e777b3d7ce Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Fri, 24 Oct 2025 18:00:29 -0700 Subject: [PATCH 14/29] fix: prevent another user's note from being added to yours --- .../features/cards/components/addCardToModal/AddCardToModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx index f53e8f37..ddc67ca8 100644 --- a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx @@ -25,7 +25,7 @@ interface Props { export default function AddCardToModal(props: Props) { const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); const isMyCard = props.cardId === cardStatus.data.card?.id; - const [note, setNote] = useState(props.note); + const [note, setNote] = useState(isMyCard ? props.note : ''); const { data, error } = useMyCollections(); const allCollections = -- 2.51.2 From 4c04c09257ed2cad03e313c8c0c06b31a77aed48 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Fri, 24 Oct 2025 18:13:18 -0700 Subject: [PATCH 15/29] feat: decrease semble icon and badge gap on navbar --- src/webapp/components/navigation/navbar/Navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/components/navigation/navbar/Navbar.tsx b/src/webapp/components/navigation/navbar/Navbar.tsx index f2bcbf03..6bc33b96 100644 --- a/src/webapp/components/navigation/navbar/Navbar.tsx +++ b/src/webapp/components/navigation/navbar/Navbar.tsx @@ -34,7 +34,7 @@ export default function Navbar() { - + Semble logo Alpha -- 2.51.2 From 2f05611506ddbb0fade3cb6df24cdc7ce371b947 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Fri, 24 Oct 2025 18:15:04 -0700 Subject: [PATCH 16/29] feat: scrollable profile tabs --- .../components/profileTabs/ProfileTabs.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/webapp/features/profile/components/profileTabs/ProfileTabs.tsx b/src/webapp/features/profile/components/profileTabs/ProfileTabs.tsx index 637a04b1..77e2b9ad 100644 --- a/src/webapp/features/profile/components/profileTabs/ProfileTabs.tsx +++ b/src/webapp/features/profile/components/profileTabs/ProfileTabs.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Tabs } from '@mantine/core'; +import { Group, ScrollAreaAutosize, Tabs } from '@mantine/core'; import TabItem from './TabItem'; import { usePathname } from 'next/navigation'; @@ -16,17 +16,21 @@ export default function ProfileTabs({ handle }: Props) { return ( - - - Profile - - - Cards - - - Collections - - + + + + + Profile + + + Cards + + + Collections + + + + ); } -- 2.51.2 From a9ff1153a6ca91baab5d003283f9343b1d901ab5 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Fri, 24 Oct 2025 18:17:05 -0700 Subject: [PATCH 17/29] feat: smaller profile avatar on mobile --- .../features/profile/components/profileHeader/ProfileHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx b/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx index 630c0546..c856668d 100644 --- a/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx +++ b/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx @@ -42,7 +42,7 @@ export default async function ProfileHeader(props: Props) { -- 2.51.2 From 2c793bd7f9a6306173e0edb6469ffe873d465c16 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 09:21:58 -0700 Subject: [PATCH 18/29] fix: url card link overflow --- src/webapp/features/cards/components/urlCard/UrlCard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/webapp/features/cards/components/urlCard/UrlCard.tsx b/src/webapp/features/cards/components/urlCard/UrlCard.tsx index 10593a39..534cee65 100644 --- a/src/webapp/features/cards/components/urlCard/UrlCard.tsx +++ b/src/webapp/features/cards/components/urlCard/UrlCard.tsx @@ -68,7 +68,13 @@ export default function UrlCard(props: Props) { {props.cardContent.title && ( - + {props.cardContent.title} )} -- 2.51.2 From bc37e7deabfc9da9c6e2b179ea5ae022c40c9b58 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 15:02:03 -0700 Subject: [PATCH 19/29] refactor: move card status to dal --- src/webapp/features/cards/lib/dal.ts | 7 +++++++ .../cards/lib/queries/useGetCardFromMyLibrary.tsx | 8 ++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/webapp/features/cards/lib/dal.ts b/src/webapp/features/cards/lib/dal.ts index ba6f7a54..77eb6b59 100644 --- a/src/webapp/features/cards/lib/dal.ts +++ b/src/webapp/features/cards/lib/dal.ts @@ -7,3 +7,10 @@ export const getUrlMetadata = cache(async (url: string) => { return response; }); + +export const getCardFromMyLibrary = cache(async (url: string) => { + const client = createSembleClient(); + const response = await client.getUrlStatusForMyLibrary({ url: url }); + + return response; +}); diff --git a/src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx b/src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx index b59e107a..a8e9d39e 100644 --- a/src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx +++ b/src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx @@ -1,18 +1,14 @@ -import { ApiClient } from '@/api-client/ApiClient'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { getCardFromMyLibrary } from '../dal'; interface Props { url: string; } export default function useGetCardFromMyLibrary(props: Props) { - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); - const cardStatus = useSuspenseQuery({ queryKey: ['card from my library', props.url], - queryFn: () => apiClient.getUrlStatusForMyLibrary({ url: props.url }), + queryFn: () => getCardFromMyLibrary(props.url), }); return cardStatus; -- 2.51.2 From c18e3adb4b2bdbd8750b2e07a363b710302a5b32 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 15:05:53 -0700 Subject: [PATCH 20/29] reafactor: dal for session verification --- src/webapp/app/api/auth/me/route.ts | 20 +------ src/webapp/lib/auth/dal.ts | 90 +++++++++++++++++++++++++++++ src/webapp/lib/auth/token.ts | 17 ++++++ 3 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 src/webapp/lib/auth/dal.ts create mode 100644 src/webapp/lib/auth/token.ts diff --git a/src/webapp/app/api/auth/me/route.ts b/src/webapp/app/api/auth/me/route.ts index 1f4433d5..521d4111 100644 --- a/src/webapp/app/api/auth/me/route.ts +++ b/src/webapp/app/api/auth/me/route.ts @@ -1,24 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; - -// Helper to check if token is expired or will expire soon -function isTokenExpiringSoon( - token: string | null | undefined, - bufferMinutes: number = 5, -): boolean { - if (!token) return true; - - try { - const payload = JSON.parse( - Buffer.from(token.split('.')[1], 'base64').toString(), - ); - const expiry = payload.exp * 1000; - const bufferTime = bufferMinutes * 60 * 1000; - return Date.now() >= expiry - bufferTime; - } catch { - return true; - } -} +import { isTokenExpiringSoon } from '@/lib/auth/token'; export async function GET(request: NextRequest) { try { diff --git a/src/webapp/lib/auth/dal.ts b/src/webapp/lib/auth/dal.ts new file mode 100644 index 00000000..bd3e6acd --- /dev/null +++ b/src/webapp/lib/auth/dal.ts @@ -0,0 +1,90 @@ +import 'server-only'; + +import { cache } from 'react'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { isTokenExpiringSoon } from './token'; + +export const verifySession = cache(async () => { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + const refreshToken = cookieStore.get('refreshToken')?.value; + + // no session tokens — redirect to login + if (!accessToken && !refreshToken) { + redirect('/login'); + } + + const backendUrl = + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; + + // token expired or about to expire + if ((!accessToken || isTokenExpiringSoon(accessToken)) && refreshToken) { + const refreshResponse = await fetch( + `${backendUrl}/api/users/oauth/refresh`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + cache: 'no-store', + }, + ); + + if (!refreshResponse.ok) { + // clear invalid tokens and redirect to login + cookieStore.delete('accessToken'); + cookieStore.delete('refreshToken'); + redirect('/login'); + } + + const newTokens = await refreshResponse.json(); + const newAccessToken = newTokens.accessToken; + + // update cookie + cookieStore.set('accessToken', newAccessToken, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + }); + + return { isAuthenticated: true }; + } + + // if access token is valid + return { isAuthenticated: true }; +}); + +export const getUser = cache(async () => { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + const refreshToken = cookieStore.get('refreshToken')?.value; + + if (!accessToken && !refreshToken) redirect('/login'); + + const backendUrl = + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; + + // Forward cookies manually + const cookieHeader = [ + accessToken ? `accessToken=${accessToken}` : null, + refreshToken ? `refreshToken=${refreshToken}` : null, + ] + .filter(Boolean) + .join('; '); + + const res = await fetch(`${backendUrl}/api/users/me`, { + headers: { + 'Content-Type': 'application/json', + Cookie: cookieHeader, // forward the cookies + }, + cache: 'no-store', + }); + + if (!res.ok) { + redirect('/login'); + } + + const user = await res.json(); + return user; +}); diff --git a/src/webapp/lib/auth/token.ts b/src/webapp/lib/auth/token.ts new file mode 100644 index 00000000..0f28f492 --- /dev/null +++ b/src/webapp/lib/auth/token.ts @@ -0,0 +1,17 @@ +export const isTokenExpiringSoon = ( + token: string | null | undefined, + bufferMinutes: number = 5, +): boolean => { + if (!token) return true; + + try { + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64').toString(), + ); + const expiry = payload.exp * 1000; + const bufferTime = bufferMinutes * 60 * 1000; + return Date.now() >= expiry - bufferTime; + } catch { + return true; + } +}; -- 2.51.2 From 822825fd76b2c01acccbb9f3c8e6d21b34f5193c Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 27 Oct 2025 15:05:54 -0700 Subject: [PATCH 21/29] test: add test case for deleting URL card and associated note card Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../RemoveCardFromLibraryUseCase.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts index 3321677b..fa3028fb 100644 --- a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts @@ -646,5 +646,39 @@ describe('RemoveCardFromLibraryUseCase', () => { const updatedCard = updatedCardResult.unwrap(); expect(updatedCard).toBeNull(); }); + + it('should delete URL card and associated note card when both have no library memberships', async () => { + const urlCard = await createCard(CardTypeEnum.URL, curatorId); + await addCardToLibrary(urlCard, curatorId); + + // Create associated note card + const noteCard = new CardBuilder() + .withType(CardTypeEnum.NOTE) + .withCuratorId(curatorId.value) + .withParentCard(urlCard.cardId) + .withUrl(urlCard.url!) + .build(); + + if (noteCard instanceof Error) throw noteCard; + + await cardRepository.save(noteCard); + await addCardToLibrary(noteCard, curatorId); + + // Remove URL card from library + const request = { + cardId: urlCard.cardId.getStringValue(), + curatorId: curatorId.value, + }; + + const result = await useCase.execute(request); + expect(result.isOk()).toBe(true); + + // Verify both cards were deleted + const urlCardResult = await cardRepository.findById(urlCard.cardId); + const noteCardResult = await cardRepository.findById(noteCard.cardId); + + expect(urlCardResult.unwrap()).toBeNull(); + expect(noteCardResult.unwrap()).toBeNull(); + }); }); }); -- 2.51.2 From 0e79b6eea445c5ecfb105d8eb6c20d3658ba8de7 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 27 Oct 2025 15:11:06 -0700 Subject: [PATCH 22/29] feat: implement cascading deletion for URL and note cards in library removal Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../commands/RemoveCardFromLibraryUseCase.ts | 29 ++++++++++++++++--- .../domain/services/CardLibraryService.ts | 22 ++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts b/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts index ae7f3855..b1b25676 100644 --- a/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts @@ -77,7 +77,7 @@ export class RemoveCardFromLibraryUseCase return err(new ValidationError(`Card not found: ${request.cardId}`)); } - // Remove card from library using domain service + // Remove card from library using domain service (this handles cascading for URL cards) const removeFromLibraryResult = await this.cardLibraryService.removeCardFromLibrary(card, curatorId); if (removeFromLibraryResult.isErr()) { @@ -88,12 +88,33 @@ export class RemoveCardFromLibraryUseCase } const updatedCard = removeFromLibraryResult.value; + + // Handle deletion with proper ordering for URL cards if ( - updatedCard.libraryCount == 0 && + updatedCard.libraryCount === 0 && updatedCard.curatorId.equals(curatorId) ) { - // If no curators have this card in their library and the curator is the owner, delete the card - const deleteResult = await this.cardRepository.delete(card.cardId); + if (updatedCard.isUrlCard && updatedCard.url) { + // First, delete any associated note card that also has no library memberships + const noteCardResult = await this.cardRepository.findUsersNoteCardByUrl( + updatedCard.url, + curatorId, + ); + + if (noteCardResult.isOk() && noteCardResult.value) { + const noteCard = noteCardResult.value; + if (noteCard.libraryCount === 0 && noteCard.curatorId.equals(curatorId)) { + // Delete note card first (child before parent) + const deleteNoteResult = await this.cardRepository.delete(noteCard.cardId); + if (deleteNoteResult.isErr()) { + return err(AppError.UnexpectedError.create(deleteNoteResult.error)); + } + } + } + } + + // Then delete the main card (URL card or any other card type) + const deleteResult = await this.cardRepository.delete(updatedCard.cardId); if (deleteResult.isErr()) { return err(AppError.UnexpectedError.create(deleteResult.error)); } diff --git a/src/modules/cards/domain/services/CardLibraryService.ts b/src/modules/cards/domain/services/CardLibraryService.ts index 91c6a83d..0000c7b6 100644 --- a/src/modules/cards/domain/services/CardLibraryService.ts +++ b/src/modules/cards/domain/services/CardLibraryService.ts @@ -155,6 +155,28 @@ export class CardLibraryService implements DomainService { } } + // Handle cascading removal for URL cards + if (card.isUrlCard && card.url) { + const noteCardResult = await this.cardRepository.findUsersNoteCardByUrl( + card.url, + curatorId, + ); + + if (noteCardResult.isOk() && noteCardResult.value) { + const noteCard = noteCardResult.value; + + // Recursively remove note card from library (this will handle its unpublishing) + const removeNoteResult = await this.removeCardFromLibrary(noteCard, curatorId); + if (removeNoteResult.isErr()) { + return err( + new CardLibraryValidationError( + `Failed to remove associated note card: ${removeNoteResult.error.message}`, + ), + ); + } + } + } + // Get library info to check if it was published const libraryInfo = card.getLibraryInfo(curatorId); if (libraryInfo?.publishedRecordId) { -- 2.51.2 From 5cf73fca0567578f44d6171bfd03e326337f4df9 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 27 Oct 2025 15:34:51 -0700 Subject: [PATCH 23/29] formatting and linting --- .../commands/RemoveCardFromLibraryUseCase.ts | 26 +++++++++++++------ .../domain/services/CardLibraryService.ts | 5 +++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts b/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts index b1b25676..e6f13763 100644 --- a/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts @@ -96,25 +96,35 @@ export class RemoveCardFromLibraryUseCase ) { if (updatedCard.isUrlCard && updatedCard.url) { // First, delete any associated note card that also has no library memberships - const noteCardResult = await this.cardRepository.findUsersNoteCardByUrl( - updatedCard.url, - curatorId, - ); + const noteCardResult = + await this.cardRepository.findUsersNoteCardByUrl( + updatedCard.url, + curatorId, + ); if (noteCardResult.isOk() && noteCardResult.value) { const noteCard = noteCardResult.value; - if (noteCard.libraryCount === 0 && noteCard.curatorId.equals(curatorId)) { + if ( + noteCard.libraryCount === 0 && + noteCard.curatorId.equals(curatorId) + ) { // Delete note card first (child before parent) - const deleteNoteResult = await this.cardRepository.delete(noteCard.cardId); + const deleteNoteResult = await this.cardRepository.delete( + noteCard.cardId, + ); if (deleteNoteResult.isErr()) { - return err(AppError.UnexpectedError.create(deleteNoteResult.error)); + return err( + AppError.UnexpectedError.create(deleteNoteResult.error), + ); } } } } // Then delete the main card (URL card or any other card type) - const deleteResult = await this.cardRepository.delete(updatedCard.cardId); + const deleteResult = await this.cardRepository.delete( + updatedCard.cardId, + ); if (deleteResult.isErr()) { return err(AppError.UnexpectedError.create(deleteResult.error)); } diff --git a/src/modules/cards/domain/services/CardLibraryService.ts b/src/modules/cards/domain/services/CardLibraryService.ts index 0000c7b6..0d89ec0c 100644 --- a/src/modules/cards/domain/services/CardLibraryService.ts +++ b/src/modules/cards/domain/services/CardLibraryService.ts @@ -166,7 +166,10 @@ export class CardLibraryService implements DomainService { const noteCard = noteCardResult.value; // Recursively remove note card from library (this will handle its unpublishing) - const removeNoteResult = await this.removeCardFromLibrary(noteCard, curatorId); + const removeNoteResult = await this.removeCardFromLibrary( + noteCard, + curatorId, + ); if (removeNoteResult.isErr()) { return err( new CardLibraryValidationError( -- 2.51.2 From c2b7eae9bda5e434c68ed5d2e7120cf11410c0cf Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 16:03:40 -0700 Subject: [PATCH 24/29] refactor: dal for profile --- .../[handle]/(withHeader)/cards/layout.tsx | 11 ++-------- .../(withHeader)/collections/layout.tsx | 11 ++-------- .../profile/[handle]/(withHeader)/layout.tsx | 11 ++-------- .../[handle]/(withHeader)/opengraph-image.tsx | 9 ++------- .../navigation/appLayout/AppLayout.tsx | 3 ++- src/webapp/features/collections/lib/dal.ts | 10 ++++++++++ .../lib/queries/useMyCollections.tsx | 15 ++++++++------ .../profileHeader/ProfileHeader.tsx | 10 ++-------- src/webapp/features/profile/lib/dal.ts | 7 +++++++ .../profile/lib/queries/useMyProfile.tsx | 20 ++++++++++--------- 10 files changed, 49 insertions(+), 58 deletions(-) diff --git a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/cards/layout.tsx b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/cards/layout.tsx index 3d9edbcf..4bcf4328 100644 --- a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/cards/layout.tsx +++ b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/cards/layout.tsx @@ -1,4 +1,4 @@ -import { ApiClient } from '@/api-client/ApiClient'; +import { getProfile } from '@/features/profile/lib/dal'; import type { Metadata } from 'next'; import { Fragment } from 'react'; @@ -9,14 +9,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { handle } = await params; - - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); - - const profile = await apiClient.getProfile({ - identifier: handle, - }); + const profile = await getProfile(handle); return { title: `${profile.name}'s cards`, diff --git a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/collections/layout.tsx b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/collections/layout.tsx index ed69b4d8..d0d8ba49 100644 --- a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/collections/layout.tsx +++ b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/collections/layout.tsx @@ -1,4 +1,4 @@ -import { ApiClient } from '@/api-client/ApiClient'; +import { getProfile } from '@/features/profile/lib/dal'; import type { Metadata } from 'next'; import { Fragment } from 'react'; @@ -9,14 +9,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { handle } = await params; - - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); - - const profile = await apiClient.getProfile({ - identifier: handle, - }); + const profile = await getProfile(handle); return { title: `${profile.name}'s collections`, diff --git a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx index 705acf84..d152aac6 100644 --- a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx +++ b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx @@ -4,9 +4,9 @@ import ProfileHeader from '@/features/profile/components/profileHeader/ProfileHe import ProfileTabs from '@/features/profile/components/profileTabs/ProfileTabs'; import { Box, Container } from '@mantine/core'; import { Fragment, Suspense } from 'react'; -import { ApiClient } from '@/api-client/ApiClient'; import ProfileHeaderSkeleton from '@/features/profile/components/profileHeader/Skeleton.ProfileHeader'; import BackButton from '@/components/navigation/backButton/BackButton'; +import { getProfile } from '@/features/profile/lib/dal'; interface Props { params: Promise<{ handle: string }>; @@ -15,14 +15,7 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { handle } = await params; - - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); - - const profile = await apiClient.getProfile({ - identifier: handle, - }); + const profile = await getProfile(handle); return { title: profile.name, diff --git a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/opengraph-image.tsx b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/opengraph-image.tsx index 4b492845..41cc7d49 100644 --- a/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/opengraph-image.tsx +++ b/src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/opengraph-image.tsx @@ -1,5 +1,5 @@ -import { ApiClient } from '@/api-client'; import OpenGraphCard from '@/features/openGraph/components/openGraphCard/OpenGraphCard'; +import { getProfile } from '@/features/profile/lib/dal'; import { truncateText } from '@/lib/utils/text'; interface Props { @@ -14,12 +14,7 @@ export const size = { export default async function Image(props: Props) { const { handle } = await props.params; - - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); - - const profile = await apiClient.getProfile({ identifier: handle }); + const profile = await getProfile(handle); return await OpenGraphCard({ children: ( diff --git a/src/webapp/components/navigation/appLayout/AppLayout.tsx b/src/webapp/components/navigation/appLayout/AppLayout.tsx index 23e2ca41..7de892e8 100644 --- a/src/webapp/components/navigation/appLayout/AppLayout.tsx +++ b/src/webapp/components/navigation/appLayout/AppLayout.tsx @@ -1,3 +1,5 @@ +'use client'; + import { AppShell } from '@mantine/core'; import Navbar from '@/components/navigation/navbar/Navbar'; import ComposerDrawer from '@/features/composer/components/composerDrawer/ComposerDrawer'; @@ -32,7 +34,6 @@ export default function AppLayout(props: Props) { collapsed: { mobile: true }, }} > - {/*
*/} diff --git a/src/webapp/features/collections/lib/dal.ts b/src/webapp/features/collections/lib/dal.ts index eb03a26f..d93c9720 100644 --- a/src/webapp/features/collections/lib/dal.ts +++ b/src/webapp/features/collections/lib/dal.ts @@ -18,3 +18,13 @@ export const getCollectionsForUrl = cache( return response; }, ); + +export const getMyCollections = cache(async (params?: PageParams) => { + const client = createSembleClient(); + const response = await client.getMyCollections({ + page: params?.page, + limit: params?.limit, + }); + + return response; +}); diff --git a/src/webapp/features/collections/lib/queries/useMyCollections.tsx b/src/webapp/features/collections/lib/queries/useMyCollections.tsx index b21d70fb..97c0199a 100644 --- a/src/webapp/features/collections/lib/queries/useMyCollections.tsx +++ b/src/webapp/features/collections/lib/queries/useMyCollections.tsx @@ -1,22 +1,25 @@ -import { ApiClient } from '@/api-client/ApiClient'; import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import { getMyCollections } from '../dal'; +import { useAuth } from '@/hooks/useAuth'; interface Props { limit?: number; } export default function useMyCollections(props?: Props) { - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + // don't trigger Suspense + return { data: null }; + } const limit = props?.limit ?? 15; return useSuspenseInfiniteQuery({ queryKey: ['collections', limit], initialPageParam: 1, - queryFn: ({ pageParam }) => - apiClient.getMyCollections({ limit, page: pageParam }), + queryFn: ({ pageParam }) => getMyCollections({ limit, page: pageParam }), getNextPageParam: (lastPage) => { return lastPage.pagination.hasMore ? lastPage.pagination.currentPage + 1 diff --git a/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx b/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx index c856668d..51625bbc 100644 --- a/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx +++ b/src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx @@ -13,20 +13,14 @@ import { import { truncateText } from '@/lib/utils/text'; import MinimalProfileHeaderContainer from '../../containers/minimalProfileHeaderContainer/MinimalProfileHeaderContainer'; import { FaBluesky } from 'react-icons/fa6'; -import { ApiClient } from '@/api-client/ApiClient'; +import { getProfile } from '../../lib/dal'; interface Props { handle: string; } export default async function ProfileHeader(props: Props) { - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); - - const profile = await apiClient.getProfile({ - identifier: props.handle, - }); + const profile = await getProfile(props.handle); return ( diff --git a/src/webapp/features/profile/lib/dal.ts b/src/webapp/features/profile/lib/dal.ts index 67678c4e..2bdd7c76 100644 --- a/src/webapp/features/profile/lib/dal.ts +++ b/src/webapp/features/profile/lib/dal.ts @@ -9,3 +9,10 @@ export const getProfile = cache(async (didOrHandle: string) => { return response; }); + +export const getMyProfile = cache(async () => { + const client = createSembleClient(); + const response = await client.getMyProfile(); + + return response; +}); diff --git a/src/webapp/features/profile/lib/queries/useMyProfile.tsx b/src/webapp/features/profile/lib/queries/useMyProfile.tsx index 6301c2c4..c10366e0 100644 --- a/src/webapp/features/profile/lib/queries/useMyProfile.tsx +++ b/src/webapp/features/profile/lib/queries/useMyProfile.tsx @@ -1,15 +1,17 @@ -import { ApiClient } from '@/api-client/ApiClient'; -import { useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery, useQuery } from '@tanstack/react-query'; +import { getMyProfile } from '../dal'; +import { useAuth } from '@/hooks/useAuth'; export default function useMyProfile() { - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); + const { isAuthenticated } = useAuth(); - const myProfile = useSuspenseQuery({ + if (!isAuthenticated) { + // don't trigger Suspense + return { data: null }; + } + + return useSuspenseQuery({ queryKey: ['my profile'], - queryFn: () => apiClient.getMyProfile(), + queryFn: () => getMyProfile(), }); - - return myProfile; } -- 2.51.2 From 940f67ebaec133f2d751ac6a2b1f7374415b4715 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 16:21:28 -0700 Subject: [PATCH 25/29] refactor: useAuth --- .../lib/queries/useMyCollections.tsx | 8 - .../profile/lib/queries/useMyProfile.tsx | 8 - src/webapp/hooks/useAuth.tsx | 142 +++++------------- 3 files changed, 35 insertions(+), 123 deletions(-) diff --git a/src/webapp/features/collections/lib/queries/useMyCollections.tsx b/src/webapp/features/collections/lib/queries/useMyCollections.tsx index 97c0199a..3e1e8757 100644 --- a/src/webapp/features/collections/lib/queries/useMyCollections.tsx +++ b/src/webapp/features/collections/lib/queries/useMyCollections.tsx @@ -1,19 +1,11 @@ import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { getMyCollections } from '../dal'; -import { useAuth } from '@/hooks/useAuth'; interface Props { limit?: number; } export default function useMyCollections(props?: Props) { - const { isAuthenticated } = useAuth(); - - if (!isAuthenticated) { - // don't trigger Suspense - return { data: null }; - } - const limit = props?.limit ?? 15; return useSuspenseInfiniteQuery({ diff --git a/src/webapp/features/profile/lib/queries/useMyProfile.tsx b/src/webapp/features/profile/lib/queries/useMyProfile.tsx index c10366e0..a3a62487 100644 --- a/src/webapp/features/profile/lib/queries/useMyProfile.tsx +++ b/src/webapp/features/profile/lib/queries/useMyProfile.tsx @@ -1,15 +1,7 @@ import { useSuspenseQuery, useQuery } from '@tanstack/react-query'; import { getMyProfile } from '../dal'; -import { useAuth } from '@/hooks/useAuth'; export default function useMyProfile() { - const { isAuthenticated } = useAuth(); - - if (!isAuthenticated) { - // don't trigger Suspense - return { data: null }; - } - return useSuspenseQuery({ queryKey: ['my profile'], queryFn: () => getMyProfile(), diff --git a/src/webapp/hooks/useAuth.tsx b/src/webapp/hooks/useAuth.tsx index bc10ae7f..f8896e33 100644 --- a/src/webapp/hooks/useAuth.tsx +++ b/src/webapp/hooks/useAuth.tsx @@ -1,142 +1,70 @@ 'use client'; -import { - useState, - useEffect, - createContext, - useContext, - ReactNode, - useCallback, -} from 'react'; +import { createContext, useContext, ReactNode, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; -import { ClientCookieAuthService } from '@/services/auth'; -import { ApiClient } from '@/api-client/ApiClient'; import type { GetProfileResponse } from '@/api-client/ApiClient'; +import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client'; type UserProfile = GetProfileResponse; -interface AuthState { +interface AuthContextType { + user: UserProfile | null; isAuthenticated: boolean; isLoading: boolean; - user: UserProfile | null; -} - -interface AuthContextType extends AuthState { - login: (handle: string) => Promise<{ authUrl: string }>; logout: () => Promise; - refreshAuth: () => Promise; } const AuthContext = createContext(undefined); export const AuthProvider = ({ children }: { children: ReactNode }) => { - const [authState, setAuthState] = useState({ - isAuthenticated: false, - isLoading: true, - user: null, - }); - const router = useRouter(); + const queryClient = useQueryClient(); + + const logout = async () => { + await ClientCookieAuthService.clearTokens(); + queryClient.removeQueries({ queryKey: ['authenticated user'] }); + router.push('/login'); + }; - // Refresh authentication (fetch user profile with automatic token refresh) - const refreshAuth = useCallback(async (): Promise => { - try { - // Call /api/auth/me which handles token refresh + profile fetch - // HttpOnly cookies sent automatically with credentials: 'include' + const query = useQuery({ + queryKey: ['authenticated user'], + queryFn: async () => { const response = await fetch('/api/auth/me', { method: 'GET', - credentials: 'include', + credentials: 'include', // HttpOnly cookies sent automatically }); + // unauthenticated if (!response.ok) { - // Clear tokens when auth fails - await ClientCookieAuthService.clearTokens(); - setAuthState({ - isAuthenticated: false, - user: null, - isLoading: false, - }); - return false; + throw new Error('Not authenticated'); } - const { user } = await response.json(); - - setAuthState({ - isAuthenticated: true, - user, - isLoading: false, - }); - - return true; - } catch (error) { - console.error('Auth refresh failed:', error); - // Clear tokens on error too - await ClientCookieAuthService.clearTokens(); - setAuthState({ - isAuthenticated: false, - user: null, - isLoading: false, - }); - return false; - } - }, []); - - // Initialize auth on mount - useEffect(() => { - refreshAuth(); - }, [refreshAuth]); + const data = await response.json(); + return data.user as UserProfile; + }, + staleTime: 5 * 60 * 1000, // cache for 5 minutes + refetchOnWindowFocus: false, + retry: false, + }); - // Periodic auth check (every 5 minutes) useEffect(() => { - if (!authState.isAuthenticated) return; + if (query.isError) logout(); + }, [query.isError, logout]); - const interval = setInterval( - async () => { - await refreshAuth(); - }, - 5 * 60 * 1000, - ); // Check every 5 minutes - - return () => clearInterval(interval); - }, [authState.isAuthenticated, refreshAuth]); - - const login = useCallback(async (handle: string) => { - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', - ); - return await apiClient.initiateOAuthSignIn({ handle }); - }, []); - - const logout = useCallback(async () => { - try { - await ClientCookieAuthService.clearTokens(); - } catch (error) { - console.error('Logout error:', error); - } finally { - setAuthState({ - isAuthenticated: false, - isLoading: false, - user: null, - }); - router.push('/login'); - } - }, [router]); + const contextValue: AuthContextType = { + user: query.data ?? null, + isAuthenticated: !!query.data, + isLoading: query.isLoading, + logout, + }; return ( - - {children} - + {children} ); }; -export const useAuth = () => { +export const useAuth = (): AuthContextType => { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); -- 2.51.2 From 8aba0b145d6527b2b4e2f3a14932de8af15de218 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 16:26:30 -0700 Subject: [PATCH 26/29] fix: missing refreshAuth --- src/webapp/hooks/useAuth.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/webapp/hooks/useAuth.tsx b/src/webapp/hooks/useAuth.tsx index f8896e33..bcebe346 100644 --- a/src/webapp/hooks/useAuth.tsx +++ b/src/webapp/hooks/useAuth.tsx @@ -12,6 +12,7 @@ interface AuthContextType { user: UserProfile | null; isAuthenticated: boolean; isLoading: boolean; + refreshAuth: () => Promise; logout: () => Promise; } @@ -21,6 +22,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const router = useRouter(); const queryClient = useQueryClient(); + const refreshAuth = async () => { + await query.refetch(); + }; + const logout = async () => { await ClientCookieAuthService.clearTokens(); queryClient.removeQueries({ queryKey: ['authenticated user'] }); @@ -56,6 +61,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { user: query.data ?? null, isAuthenticated: !!query.data, isLoading: query.isLoading, + refreshAuth, logout, }; -- 2.51.2 From 5072a7b0e45cfa1d0e8beb2f61b835e718968f7d Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 16:33:54 -0700 Subject: [PATCH 27/29] feat: log out on unauthenticated --- src/webapp/hooks/useAuth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/hooks/useAuth.tsx b/src/webapp/hooks/useAuth.tsx index bcebe346..27921264 100644 --- a/src/webapp/hooks/useAuth.tsx +++ b/src/webapp/hooks/useAuth.tsx @@ -42,7 +42,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // unauthenticated if (!response.ok) { - throw new Error('Not authenticated'); + await logout(); } const data = await response.json(); -- 2.51.2 From 55f4cdcbb8fdbfa520cabc66cef642e2be0ff745 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Mon, 27 Oct 2025 16:41:38 -0700 Subject: [PATCH 28/29] Revert "feat: log out on unauthenticated" This reverts commit 5072a7b0e45cfa1d0e8beb2f61b835e718968f7d. --- src/webapp/hooks/useAuth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/hooks/useAuth.tsx b/src/webapp/hooks/useAuth.tsx index 27921264..bcebe346 100644 --- a/src/webapp/hooks/useAuth.tsx +++ b/src/webapp/hooks/useAuth.tsx @@ -42,7 +42,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // unauthenticated if (!response.ok) { - await logout(); + throw new Error('Not authenticated'); } const data = await response.json(); -- 2.51.2 From e909262f68355076146378fd5ca37d5b2b0fc3ae Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Tue, 28 Oct 2025 09:53:03 -0700 Subject: [PATCH 29/29] refactor: defer card status query until modal opens to prevent premature suspense --- .../addCardDrawer/AddCardDrawer.tsx | 3 +- .../addCardToModal/AddCardToModal.tsx | 134 +--------------- .../addCardToModal/AddCardToModalContent.tsx | 143 ++++++++++++++++++ .../cards/components/urlCard/UrlCard.tsx | 36 ++--- 4 files changed, 163 insertions(+), 153 deletions(-) create mode 100644 src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx diff --git a/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx b/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx index 2b70edbe..c9aefdc4 100644 --- a/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx +++ b/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx @@ -1,6 +1,5 @@ import { - Button, - Center, + Button, Container, Drawer, Group, diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx index ddc67ca8..ff153ab3 100644 --- a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx @@ -1,16 +1,9 @@ import type { UrlCard } from '@/api-client'; import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; import { Modal, Stack, Text } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import { Suspense, useState } from 'react'; -import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; -import CardToBeAddedPreview from '../cardToBeAddedPreview/CardToBeAddedPreview'; -import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary'; -import useMyCollections from '../../../collections/lib/queries/useMyCollections'; -import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; -import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations'; +import { Suspense } from 'react'; import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; -import useAddCard from '../../lib/mutations/useAddCard'; +import AddCardToModalContent from './AddCardToModalContent'; // new file or inline interface Props { isOpen: boolean; @@ -23,111 +16,14 @@ interface Props { } export default function AddCardToModal(props: Props) { - const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); - const isMyCard = props.cardId === cardStatus.data.card?.id; - const [note, setNote] = useState(isMyCard ? props.note : ''); - const { data, error } = useMyCollections(); - - const allCollections = - data?.pages.flatMap((page) => page.collections ?? []) ?? []; - - const collectionsWithCard = allCollections.filter((c) => - cardStatus.data.collections?.some((col) => col.id === c.id), - ); - - const [selectedCollections, setSelectedCollections] = - useState(collectionsWithCard); - - const addCard = useAddCard(); - const updateCardAssociations = useUpdateCardAssociations(); - - const handleUpdateCard = (e: React.FormEvent) => { - e.preventDefault(); - - const trimmedNote = note?.trimEnd() === '' ? undefined : note; - - const addedCollections = selectedCollections.filter( - (c) => !collectionsWithCard.some((original) => original.id === c.id), - ); - - const removedCollections = collectionsWithCard.filter( - (c) => !selectedCollections.some((selected) => selected.id === c.id), - ); - - const hasNoteChanged = trimmedNote !== props.note; - const hasAdded = addedCollections.length > 0; - const hasRemoved = removedCollections.length > 0; - - if (cardStatus.data.card && !hasNoteChanged && !hasAdded && !hasRemoved) { - props.onClose(); - return; - } - - // if the card is not in library, add it instead of updating - if (!cardStatus.data.card) { - addCard.mutate( - { - url: props.cardContent.url, - note: trimmedNote, - collectionIds: selectedCollections.map((c) => c.id), - }, - { - onError: () => { - notifications.show({ - message: 'Could not add card.', - }); - }, - onSettled: () => { - props.onClose(); - }, - }, - ); - return; - } - - // otherwise, update existing card associations - const updatedCardPayload: { - cardId: string; - note?: string; - addToCollectionIds?: string[]; - removeFromCollectionIds?: string[]; - } = { cardId: cardStatus.data.card!.id }; - - if (hasNoteChanged) updatedCardPayload.note = trimmedNote; - if (hasAdded) - updatedCardPayload.addToCollectionIds = addedCollections.map((c) => c.id); - if (hasRemoved) - updatedCardPayload.removeFromCollectionIds = removedCollections.map( - (c) => c.id, - ); - - updateCardAssociations.mutate(updatedCardPayload, { - onError: () => { - notifications.show({ - message: 'Could not update card.', - }); - }, - onSettled: () => { - props.onClose(); - }, - }); - }; - - if (error) { - return ; - } - return ( { - props.onClose(); - setSelectedCollections(collectionsWithCard); - }} + onClose={props.onClose} title={ Add or update card - + {props.isInYourLibrary ? props.urlLibraryCount === 1 ? 'Saved by you' @@ -142,27 +38,11 @@ export default function AddCardToModal(props: Props) { centered onClick={(e) => e.stopPropagation()} > - - - + {props.isOpen && ( }> - { - props.onClose(); - setSelectedCollections(collectionsWithCard); - }} - onSave={handleUpdateCard} - selectedCollections={selectedCollections} - onSelectedCollectionsChange={setSelectedCollections} - /> + - + )} ); } diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx new file mode 100644 index 00000000..70431612 --- /dev/null +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx @@ -0,0 +1,143 @@ +'use client'; + +import type { UrlCard } from '@/api-client'; +import { Stack } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { useState } from 'react'; +import CollectionSelectorError from '@/features/collections/components/collectionSelector/Error.CollectionSelector'; +import CardToBeAddedPreview from '@/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview'; +import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; +import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; +import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; +import useUpdateCardAssociations from '@/features/cards/lib/mutations/useUpdateCardAssociations'; +import useAddCard from '@/features/cards/lib/mutations/useAddCard'; + +interface SelectableCollectionItem { + id: string; + name: string; + cardCount: number; +} + +interface Props { + onClose: () => void; + cardContent: UrlCard['cardContent']; + urlLibraryCount: number; + cardId: string; + note?: string; + isInYourLibrary: boolean; +} + +export default function AddCardToModalContent(props: Props) { + const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); + const isMyCard = props.cardId === cardStatus.data.card?.id; + const [note, setNote] = useState(isMyCard ? props.note : ''); + const { data, error } = useMyCollections(); + + const addCard = useAddCard(); + const updateCardAssociations = useUpdateCardAssociations(); + + if (error) { + return ; + } + + const allCollections = + data?.pages.flatMap((page) => page.collections ?? []) ?? []; + + const collectionsWithCard = allCollections.filter((c) => + cardStatus.data.collections?.some((col) => col.id === c.id), + ); + + const [selectedCollections, setSelectedCollections] = + useState(collectionsWithCard); + + const handleUpdateCard = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedNote = note?.trimEnd() === '' ? undefined : note; + + const addedCollections = selectedCollections.filter( + (c) => !collectionsWithCard.some((original) => original.id === c.id), + ); + + const removedCollections = collectionsWithCard.filter( + (c) => !selectedCollections.some((selected) => selected.id === c.id), + ); + + const hasNoteChanged = trimmedNote !== props.note; + const hasAdded = addedCollections.length > 0; + const hasRemoved = removedCollections.length > 0; + + // no change, close modal + if (cardStatus.data.card && !hasNoteChanged && !hasAdded && !hasRemoved) { + props.onClose(); + return; + } + + // card not yet in library, add it + if (!cardStatus.data.card) { + addCard.mutate( + { + url: props.cardContent.url, + note: trimmedNote, + collectionIds: selectedCollections.map((c) => c.id), + }, + { + onError: () => { + notifications.show({ message: 'Could not add card.' }); + }, + onSettled: () => { + props.onClose(); + }, + }, + ); + return; + } + + // card already in library, update associations instead + const updatedCardPayload: { + cardId: string; + note?: string; + addToCollectionIds?: string[]; + removeFromCollectionIds?: string[]; + } = { cardId: cardStatus.data.card.id }; + + if (hasNoteChanged) updatedCardPayload.note = trimmedNote; + if (hasAdded) + updatedCardPayload.addToCollectionIds = addedCollections.map((c) => c.id); + if (hasRemoved) + updatedCardPayload.removeFromCollectionIds = removedCollections.map( + (c) => c.id, + ); + + updateCardAssociations.mutate(updatedCardPayload, { + onError: () => { + notifications.show({ message: 'Could not update card.' }); + }, + onSettled: () => { + props.onClose(); + }, + }); + }; + + return ( + + + + { + props.onClose(); + setSelectedCollections(collectionsWithCard); + }} + onSave={handleUpdateCard} + selectedCollections={selectedCollections} + onSelectedCollectionsChange={setSelectedCollections} + /> + + ); +} diff --git a/src/webapp/features/cards/components/urlCard/UrlCard.tsx b/src/webapp/features/cards/components/urlCard/UrlCard.tsx index 534cee65..dcc51ed3 100644 --- a/src/webapp/features/cards/components/urlCard/UrlCard.tsx +++ b/src/webapp/features/cards/components/urlCard/UrlCard.tsx @@ -8,12 +8,11 @@ import { Group, Anchor, AspectRatio, - Skeleton, Tooltip, } from '@mantine/core'; import Link from 'next/link'; import UrlCardActions from '../urlCardActions/UrlCardActions'; -import { MouseEvent, Suspense } from 'react'; +import { MouseEvent } from 'react'; import { useRouter } from 'next/navigation'; import styles from './UrlCard.module.css'; @@ -97,28 +96,17 @@ export default function UrlCard(props: Props) { )} - - - - - - - } - > - - + );