A social knowledge tool for researchers built on ATProto

Compare changes

Choose any two refs to compare.

Changed files
+1254 -323
.vscode
src
modules
webapp
app
(dashboard)
bookmarklet
components
navigation
bottomBarItem
guestNavbar
features
auth
components
loginForm
signUpForm
cards
components
cardToBeAddedPreview
urlCard
lib
collections
feeds
home
notes
components
noteCard
noteCardModal
profile
components
profileHeader
profileHoverCard
profileMenu
semble
containers
providers
-1
.gitignore
··· 9 9 build 10 10 *storybook.log 11 11 storybook-static 12 - .vscode/settings.json
+3
.vscode/settings.json
··· 1 + { 2 + "jest.runMode": "on-demand" 3 + }
+9
LICENSE
··· 1 + MIT License 2 + 3 + Copyright 2025 Homeworld Collective Inc. 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the โ€œSoftwareโ€), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 + 7 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 + 9 + THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2 -2
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
··· 107 107 // Create URL card 108 108 const urlCardInput: IUrlCardInput = { 109 109 type: CardTypeEnum.URL, 110 - url: request.url, 110 + url: url.value, 111 111 metadata: metadataResult.value, 112 112 }; 113 113 ··· 192 192 type: CardTypeEnum.NOTE, 193 193 text: request.note, 194 194 parentCardId: urlCard.cardId.getStringValue(), 195 - url: request.url, 195 + url: url.value, 196 196 }; 197 197 198 198 const noteCardResult = CardFactory.create({
+1 -1
src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts
··· 60 60 try { 61 61 // Execute query to get collections containing cards with this URL (raw data with authorId) 62 62 const result = await this.collectionQueryRepo.getCollectionsWithUrl( 63 - query.url, 63 + urlResult.value.value, 64 64 { 65 65 page, 66 66 limit,
+10 -7
src/modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase.ts
··· 63 63 // Set defaults 64 64 const page = query.page || 1; 65 65 const limit = Math.min(query.limit || 20, 100); // Cap at 100 66 - const sortBy = query.sortBy || CardSortField.UPDATED_AT; 66 + const sortBy = query.sortBy || CardSortField.CREATED_AT; 67 67 const sortOrder = query.sortOrder || SortOrder.DESC; 68 68 69 69 try { 70 70 // Execute query to get libraries with full card data 71 - const result = await this.cardQueryRepo.getLibrariesForUrl(query.url, { 72 - page, 73 - limit, 74 - sortBy, 75 - sortOrder, 76 - }); 71 + const result = await this.cardQueryRepo.getLibrariesForUrl( 72 + urlResult.value.value, 73 + { 74 + page, 75 + limit, 76 + sortBy, 77 + sortOrder, 78 + }, 79 + ); 77 80 78 81 // Enrich with user profiles 79 82 const uniqueUserIds = Array.from(
+9 -6
src/modules/cards/application/useCases/queries/GetNoteCardsForUrlUseCase.ts
··· 58 58 59 59 try { 60 60 // Execute query to get note cards for the URL (raw data with authorId) 61 - const result = await this.cardQueryRepo.getNoteCardsForUrl(query.url, { 62 - page, 63 - limit, 64 - sortBy, 65 - sortOrder, 66 - }); 61 + const result = await this.cardQueryRepo.getNoteCardsForUrl( 62 + urlResult.value.value, 63 + { 64 + page, 65 + limit, 66 + sortBy, 67 + sortOrder, 68 + }, 69 + ); 67 70 68 71 // Enrich with author profiles 69 72 const uniqueAuthorIds = Array.from(
+1 -1
src/modules/cards/application/useCases/queries/GetUrlMetadataUseCase.ts
··· 62 62 63 63 return ok({ 64 64 metadata: { 65 - url: metadata.url, 65 + url: url.value, 66 66 title: metadata.title, 67 67 description: metadata.description, 68 68 author: metadata.author,
+15 -2
src/modules/cards/domain/value-objects/URL.ts
··· 30 30 31 31 try { 32 32 // Validate URL format using the global URL constructor 33 - new globalThis.URL(trimmedUrl); 34 - return ok(new URL({ value: trimmedUrl })); 33 + const parsedUrl = new globalThis.URL(trimmedUrl); 34 + 35 + // Add trailing slash only to truly bare root URLs 36 + // (no path, no query parameters, no fragments) 37 + let normalizedUrl = trimmedUrl; 38 + if ( 39 + (parsedUrl.pathname === '' || parsedUrl.pathname === '/') && 40 + parsedUrl.search === '' && 41 + parsedUrl.hash === '' && 42 + !trimmedUrl.endsWith('/') 43 + ) { 44 + normalizedUrl = trimmedUrl + '/'; 45 + } 46 + 47 + return ok(new URL({ value: normalizedUrl })); 35 48 } catch (error) { 36 49 return err(new InvalidURLError('Invalid URL format')); 37 50 }
+5 -1
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 359 359 options: CardQueryOptions, 360 360 ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> { 361 361 try { 362 - const { page, limit } = options; 362 + const { page, limit, sortBy, sortOrder } = options; 363 363 const offset = (page - 1) * limit; 364 + 365 + // Build the sort order 366 + const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 364 367 365 368 // Get all URL cards with this URL and their library memberships 366 369 const librariesQuery = this.db ··· 376 379 .from(libraryMemberships) 377 380 .innerJoin(cards, eq(libraryMemberships.cardId, cards.id)) 378 381 .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL))) 382 + .orderBy(orderDirection(this.getSortColumn(sortBy))) 379 383 .limit(limit) 380 384 .offset(offset); 381 385
+1 -1
src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts
··· 319 319 expect(result.isOk()).toBe(true); 320 320 const response = result.unwrap(); 321 321 322 - expect(response.sorting.sortBy).toBe(CardSortField.UPDATED_AT); 322 + expect(response.sorting.sortBy).toBe(CardSortField.CREATED_AT); 323 323 expect(response.sorting.sortOrder).toBe(SortOrder.DESC); 324 324 }); 325 325
+367
src/modules/cards/tests/domain/value-objects/URL.test.ts
··· 1 + import { URL, InvalidURLError } from '../../../domain/value-objects/URL'; 2 + 3 + describe('URL Value Object', () => { 4 + describe('create', () => { 5 + describe('valid URLs', () => { 6 + it('should create URL with valid http URL', () => { 7 + const result = URL.create('http://example.com'); 8 + 9 + expect(result.isOk()).toBe(true); 10 + expect(result.unwrap().value).toBe('http://example.com/'); 11 + }); 12 + 13 + it('should create URL with valid https URL', () => { 14 + const result = URL.create('https://example.com'); 15 + 16 + expect(result.isOk()).toBe(true); 17 + expect(result.unwrap().value).toBe('https://example.com/'); 18 + }); 19 + 20 + it('should create URL with subdomain', () => { 21 + const result = URL.create('https://www.example.com'); 22 + 23 + expect(result.isOk()).toBe(true); 24 + expect(result.unwrap().value).toBe('https://www.example.com/'); 25 + }); 26 + 27 + it('should create URL with port', () => { 28 + const result = URL.create('https://example.com:8080'); 29 + 30 + expect(result.isOk()).toBe(true); 31 + expect(result.unwrap().value).toBe('https://example.com:8080/'); 32 + }); 33 + }); 34 + 35 + describe('trailing slash normalization', () => { 36 + describe('should add trailing slash to bare root domains', () => { 37 + it('should add trailing slash to http://example.com', () => { 38 + const result = URL.create('http://example.com'); 39 + 40 + expect(result.isOk()).toBe(true); 41 + expect(result.unwrap().value).toBe('http://example.com/'); 42 + }); 43 + 44 + it('should add trailing slash to https://example.com', () => { 45 + const result = URL.create('https://example.com'); 46 + 47 + expect(result.isOk()).toBe(true); 48 + expect(result.unwrap().value).toBe('https://example.com/'); 49 + }); 50 + 51 + it('should add trailing slash to https://www.example.com', () => { 52 + const result = URL.create('https://www.example.com'); 53 + 54 + expect(result.isOk()).toBe(true); 55 + expect(result.unwrap().value).toBe('https://www.example.com/'); 56 + }); 57 + 58 + it('should add trailing slash to https://example.com:8080', () => { 59 + const result = URL.create('https://example.com:8080'); 60 + 61 + expect(result.isOk()).toBe(true); 62 + expect(result.unwrap().value).toBe('https://example.com:8080/'); 63 + }); 64 + 65 + it('should add trailing slash to https://sub.domain.example.com', () => { 66 + const result = URL.create('https://sub.domain.example.com'); 67 + 68 + expect(result.isOk()).toBe(true); 69 + expect(result.unwrap().value).toBe('https://sub.domain.example.com/'); 70 + }); 71 + }); 72 + 73 + describe('should NOT add trailing slash when already present', () => { 74 + it('should not add trailing slash to https://example.com/', () => { 75 + const result = URL.create('https://example.com/'); 76 + 77 + expect(result.isOk()).toBe(true); 78 + expect(result.unwrap().value).toBe('https://example.com/'); 79 + }); 80 + 81 + it('should not add trailing slash to https://www.example.com/', () => { 82 + const result = URL.create('https://www.example.com/'); 83 + 84 + expect(result.isOk()).toBe(true); 85 + expect(result.unwrap().value).toBe('https://www.example.com/'); 86 + }); 87 + 88 + it('should not add trailing slash to https://example.com:8080/', () => { 89 + const result = URL.create('https://example.com:8080/'); 90 + 91 + expect(result.isOk()).toBe(true); 92 + expect(result.unwrap().value).toBe('https://example.com:8080/'); 93 + }); 94 + }); 95 + 96 + describe('should NOT add trailing slash to URLs with paths', () => { 97 + it('should not add trailing slash to https://example.com/path', () => { 98 + const result = URL.create('https://example.com/path'); 99 + 100 + expect(result.isOk()).toBe(true); 101 + expect(result.unwrap().value).toBe('https://example.com/path'); 102 + }); 103 + 104 + it('should not add trailing slash to https://example.com/path/subpath', () => { 105 + const result = URL.create('https://example.com/path/subpath'); 106 + 107 + expect(result.isOk()).toBe(true); 108 + expect(result.unwrap().value).toBe( 109 + 'https://example.com/path/subpath', 110 + ); 111 + }); 112 + 113 + it('should not add trailing slash to https://example.com/path/', () => { 114 + const result = URL.create('https://example.com/path/'); 115 + 116 + expect(result.isOk()).toBe(true); 117 + expect(result.unwrap().value).toBe('https://example.com/path/'); 118 + }); 119 + 120 + it('should not add trailing slash to https://example.com/path.html', () => { 121 + const result = URL.create('https://example.com/path.html'); 122 + 123 + expect(result.isOk()).toBe(true); 124 + expect(result.unwrap().value).toBe('https://example.com/path.html'); 125 + }); 126 + 127 + it('should not add trailing slash to https://example.com/api/v1/users', () => { 128 + const result = URL.create('https://example.com/api/v1/users'); 129 + 130 + expect(result.isOk()).toBe(true); 131 + expect(result.unwrap().value).toBe( 132 + 'https://example.com/api/v1/users', 133 + ); 134 + }); 135 + }); 136 + 137 + describe('should NOT add trailing slash to URLs with query parameters', () => { 138 + it('should not add trailing slash to https://example.com?param=value', () => { 139 + const result = URL.create('https://example.com?param=value'); 140 + 141 + expect(result.isOk()).toBe(true); 142 + expect(result.unwrap().value).toBe('https://example.com?param=value'); 143 + }); 144 + 145 + it('should not add trailing slash to https://example.com?param1=value1&param2=value2', () => { 146 + const result = URL.create( 147 + 'https://example.com?param1=value1&param2=value2', 148 + ); 149 + 150 + expect(result.isOk()).toBe(true); 151 + expect(result.unwrap().value).toBe( 152 + 'https://example.com?param1=value1&param2=value2', 153 + ); 154 + }); 155 + 156 + it('should not add trailing slash to https://example.com/?param=value', () => { 157 + const result = URL.create('https://example.com/?param=value'); 158 + 159 + expect(result.isOk()).toBe(true); 160 + expect(result.unwrap().value).toBe( 161 + 'https://example.com/?param=value', 162 + ); 163 + }); 164 + 165 + it('should not add trailing slash to https://example.com/path?param=value', () => { 166 + const result = URL.create('https://example.com/path?param=value'); 167 + 168 + expect(result.isOk()).toBe(true); 169 + expect(result.unwrap().value).toBe( 170 + 'https://example.com/path?param=value', 171 + ); 172 + }); 173 + }); 174 + 175 + describe('should NOT add trailing slash to URLs with fragments', () => { 176 + it('should not add trailing slash to https://example.com#section', () => { 177 + const result = URL.create('https://example.com#section'); 178 + 179 + expect(result.isOk()).toBe(true); 180 + expect(result.unwrap().value).toBe('https://example.com#section'); 181 + }); 182 + 183 + it('should not add trailing slash to https://example.com/#section', () => { 184 + const result = URL.create('https://example.com/#section'); 185 + 186 + expect(result.isOk()).toBe(true); 187 + expect(result.unwrap().value).toBe('https://example.com/#section'); 188 + }); 189 + 190 + it('should not add trailing slash to https://example.com/path#section', () => { 191 + const result = URL.create('https://example.com/path#section'); 192 + 193 + expect(result.isOk()).toBe(true); 194 + expect(result.unwrap().value).toBe( 195 + 'https://example.com/path#section', 196 + ); 197 + }); 198 + 199 + it('should not add trailing slash to https://example.com?param=value#section', () => { 200 + const result = URL.create('https://example.com?param=value#section'); 201 + 202 + expect(result.isOk()).toBe(true); 203 + expect(result.unwrap().value).toBe( 204 + 'https://example.com?param=value#section', 205 + ); 206 + }); 207 + }); 208 + 209 + describe('should NOT add trailing slash to URLs with query parameters AND fragments', () => { 210 + it('should not add trailing slash to https://example.com?param=value#section', () => { 211 + const result = URL.create('https://example.com?param=value#section'); 212 + 213 + expect(result.isOk()).toBe(true); 214 + expect(result.unwrap().value).toBe( 215 + 'https://example.com?param=value#section', 216 + ); 217 + }); 218 + 219 + it('should not add trailing slash to https://example.com/path?param=value#section', () => { 220 + const result = URL.create( 221 + 'https://example.com/path?param=value#section', 222 + ); 223 + 224 + expect(result.isOk()).toBe(true); 225 + expect(result.unwrap().value).toBe( 226 + 'https://example.com/path?param=value#section', 227 + ); 228 + }); 229 + }); 230 + }); 231 + 232 + describe('edge cases', () => { 233 + it('should handle URLs with IP addresses', () => { 234 + const result = URL.create('http://192.168.1.1'); 235 + 236 + expect(result.isOk()).toBe(true); 237 + expect(result.unwrap().value).toBe('http://192.168.1.1/'); 238 + }); 239 + 240 + it('should handle URLs with IP addresses and ports', () => { 241 + const result = URL.create('http://192.168.1.1:8080'); 242 + 243 + expect(result.isOk()).toBe(true); 244 + expect(result.unwrap().value).toBe('http://192.168.1.1:8080/'); 245 + }); 246 + 247 + it('should handle localhost', () => { 248 + const result = URL.create('http://localhost'); 249 + 250 + expect(result.isOk()).toBe(true); 251 + expect(result.unwrap().value).toBe('http://localhost/'); 252 + }); 253 + 254 + it('should handle localhost with port', () => { 255 + const result = URL.create('http://localhost:3000'); 256 + 257 + expect(result.isOk()).toBe(true); 258 + expect(result.unwrap().value).toBe('http://localhost:3000/'); 259 + }); 260 + 261 + it('should trim whitespace before processing', () => { 262 + const result = URL.create(' https://example.com '); 263 + 264 + expect(result.isOk()).toBe(true); 265 + expect(result.unwrap().value).toBe('https://example.com/'); 266 + }); 267 + 268 + it('should handle URLs with authentication', () => { 269 + const result = URL.create('https://user:pass@example.com'); 270 + 271 + expect(result.isOk()).toBe(true); 272 + expect(result.unwrap().value).toBe('https://user:pass@example.com/'); 273 + }); 274 + it('should handle URLs with multiple slashes', () => { 275 + const result = URL.create('https://example.com//'); 276 + 277 + expect(result.isOk()).toBe(true); 278 + expect(result.unwrap().value).toBe('https://example.com//'); 279 + }); 280 + }); 281 + 282 + describe('invalid URLs', () => { 283 + it('should fail for empty string', () => { 284 + const result = URL.create(''); 285 + 286 + if (!result.isErr()) { 287 + throw new Error('Expected result to be an error'); 288 + } 289 + expect(result.isErr()).toBe(true); 290 + expect(result.error).toBeInstanceOf(InvalidURLError); 291 + expect(result.error.message).toBe('URL cannot be empty'); 292 + }); 293 + 294 + it('should fail for whitespace only', () => { 295 + const result = URL.create(' '); 296 + 297 + if (!result.isErr()) { 298 + throw new Error('Expected result to be an error'); 299 + } 300 + expect(result.isErr()).toBe(true); 301 + expect(result.error).toBeInstanceOf(InvalidURLError); 302 + expect(result.error.message).toBe('URL cannot be empty'); 303 + }); 304 + 305 + it('should fail for invalid URL format', () => { 306 + const result = URL.create('not-a-url'); 307 + 308 + if (!result.isErr()) { 309 + throw new Error('Expected result to be an error'); 310 + } 311 + expect(result.isErr()).toBe(true); 312 + expect(result.error).toBeInstanceOf(InvalidURLError); 313 + expect(result.error.message).toBe('Invalid URL format'); 314 + }); 315 + 316 + it('should fail for URL without protocol', () => { 317 + const result = URL.create('example.com'); 318 + 319 + if (!result.isErr()) { 320 + throw new Error('Expected result to be an error'); 321 + } 322 + expect(result.isErr()).toBe(true); 323 + expect(result.error).toBeInstanceOf(InvalidURLError); 324 + expect(result.error.message).toBe('Invalid URL format'); 325 + }); 326 + 327 + it('should fail for malformed URL', () => { 328 + const result = URL.create('https://'); 329 + 330 + if (!result.isErr()) { 331 + throw new Error('Expected result to be an error'); 332 + } 333 + expect(result.isErr()).toBe(true); 334 + expect(result.error).toBeInstanceOf(InvalidURLError); 335 + expect(result.error.message).toBe('Invalid URL format'); 336 + }); 337 + }); 338 + }); 339 + 340 + describe('toString', () => { 341 + it('should return the URL value as string', () => { 342 + const url = URL.create('https://example.com/path').unwrap(); 343 + 344 + expect(url.toString()).toBe('https://example.com/path'); 345 + }); 346 + 347 + it('should return normalized URL with trailing slash for bare domains', () => { 348 + const url = URL.create('https://example.com').unwrap(); 349 + 350 + expect(url.toString()).toBe('https://example.com/'); 351 + }); 352 + }); 353 + 354 + describe('value getter', () => { 355 + it('should return the URL value', () => { 356 + const url = URL.create('https://example.com/path').unwrap(); 357 + 358 + expect(url.value).toBe('https://example.com/path'); 359 + }); 360 + 361 + it('should return normalized URL with trailing slash for bare domains', () => { 362 + const url = URL.create('https://example.com').unwrap(); 363 + 364 + expect(url.value).toBe('https://example.com/'); 365 + }); 366 + }); 367 + });
+276
src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getLibrariesForUrl.integration.test.ts
··· 15 15 import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; 16 16 import { createTestSchema } from '../test-utils/createTestSchema'; 17 17 import { CardTypeEnum } from '../../domain/value-objects/CardType'; 18 + import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 18 19 19 20 describe('DrizzleCardQueryRepository - getLibrariesForUrl', () => { 20 21 let container: StartedPostgreSqlContainer; ··· 281 282 // Should return empty since card is not in any library 282 283 expect(result.items).toHaveLength(0); 283 284 expect(result.totalCount).toBe(0); 285 + }); 286 + }); 287 + 288 + describe('sorting', () => { 289 + it('should sort by createdAt in descending order by default', async () => { 290 + const testUrl = 'https://example.com/sort-test'; 291 + const url = URL.create(testUrl).unwrap(); 292 + 293 + // Create cards with different creation times 294 + const card1 = new CardBuilder() 295 + .withCuratorId(curator1.value) 296 + .withType(CardTypeEnum.URL) 297 + .withUrl(url) 298 + .buildOrThrow(); 299 + 300 + await new Promise((resolve) => setTimeout(resolve, 1000)); 301 + const card2 = new CardBuilder() 302 + .withCuratorId(curator2.value) 303 + .withType(CardTypeEnum.URL) 304 + .withUrl(url) 305 + .buildOrThrow(); 306 + 307 + await new Promise((resolve) => setTimeout(resolve, 1000)); 308 + const card3 = new CardBuilder() 309 + .withCuratorId(curator3.value) 310 + .withType(CardTypeEnum.URL) 311 + .withUrl(url) 312 + .buildOrThrow(); 313 + 314 + card1.addToLibrary(curator1); 315 + card2.addToLibrary(curator2); 316 + card3.addToLibrary(curator3); 317 + 318 + // Save cards with slight delays to ensure different timestamps 319 + await cardRepository.save(card1); 320 + await new Promise((resolve) => setTimeout(resolve, 10)); 321 + await cardRepository.save(card2); 322 + await new Promise((resolve) => setTimeout(resolve, 10)); 323 + await cardRepository.save(card3); 324 + 325 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 326 + page: 1, 327 + limit: 10, 328 + sortBy: CardSortField.CREATED_AT, 329 + sortOrder: SortOrder.DESC, 330 + }); 331 + 332 + expect(result.items).toHaveLength(3); 333 + 334 + // Should be sorted by creation time, newest first 335 + const cardIds = result.items.map((lib) => lib.card.id); 336 + expect(cardIds[0]).toBe(card3.cardId.getStringValue()); // Most recent 337 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Middle 338 + expect(cardIds[2]).toBe(card1.cardId.getStringValue()); // Oldest 339 + }); 340 + 341 + it('should sort by createdAt in ascending order when specified', async () => { 342 + const testUrl = 'https://example.com/sort-asc-test'; 343 + const url = URL.create(testUrl).unwrap(); 344 + 345 + // Create cards with different creation times 346 + const card1 = new CardBuilder() 347 + .withCuratorId(curator1.value) 348 + .withType(CardTypeEnum.URL) 349 + .withUrl(url) 350 + .buildOrThrow(); 351 + 352 + const card2 = new CardBuilder() 353 + .withCuratorId(curator2.value) 354 + .withType(CardTypeEnum.URL) 355 + .withUrl(url) 356 + .buildOrThrow(); 357 + 358 + card1.addToLibrary(curator1); 359 + card2.addToLibrary(curator2); 360 + 361 + // Save cards with slight delay to ensure different timestamps 362 + await cardRepository.save(card1); 363 + await new Promise((resolve) => setTimeout(resolve, 10)); 364 + await cardRepository.save(card2); 365 + 366 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 367 + page: 1, 368 + limit: 10, 369 + sortBy: CardSortField.CREATED_AT, 370 + sortOrder: SortOrder.ASC, 371 + }); 372 + 373 + expect(result.items).toHaveLength(2); 374 + 375 + // Should be sorted by creation time, oldest first 376 + const cardIds = result.items.map((lib) => lib.card.id); 377 + expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Oldest 378 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Newest 379 + }); 380 + 381 + it('should sort by updatedAt in descending order', async () => { 382 + const testUrl = 'https://example.com/sort-updated-test'; 383 + const url = URL.create(testUrl).unwrap(); 384 + 385 + // Create cards 386 + const card1 = new CardBuilder() 387 + .withCuratorId(curator1.value) 388 + .withType(CardTypeEnum.URL) 389 + .withUrl(url) 390 + .buildOrThrow(); 391 + 392 + const card2 = new CardBuilder() 393 + .withCuratorId(curator2.value) 394 + .withType(CardTypeEnum.URL) 395 + .withUrl(url) 396 + .buildOrThrow(); 397 + 398 + card1.addToLibrary(curator1); 399 + card2.addToLibrary(curator2); 400 + 401 + // Save cards 402 + await cardRepository.save(card1); 403 + await cardRepository.save(card2); 404 + 405 + // Update card1 to have a more recent updatedAt 406 + await new Promise((resolve) => setTimeout(resolve, 1000)); 407 + card1.markAsPublished( 408 + PublishedRecordId.create({ 409 + uri: 'at://did:plc:publishedrecord1', 410 + cid: 'bafyreicpublishedrecord1', 411 + }), 412 + ); 413 + await cardRepository.save(card1); // This should update the updatedAt timestamp 414 + 415 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 416 + page: 1, 417 + limit: 10, 418 + sortBy: CardSortField.UPDATED_AT, 419 + sortOrder: SortOrder.DESC, 420 + }); 421 + 422 + expect(result.items).toHaveLength(2); 423 + 424 + // card1 should be first since it was updated more recently 425 + const cardIds = result.items.map((lib) => lib.card.id); 426 + expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Most recently updated 427 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Less recently updated 428 + }); 429 + 430 + it('should sort by libraryCount in descending order', async () => { 431 + const testUrl = 'https://example.com/sort-library-count-test'; 432 + const url = URL.create(testUrl).unwrap(); 433 + 434 + // Create cards 435 + const card1 = new CardBuilder() 436 + .withCuratorId(curator1.value) 437 + .withType(CardTypeEnum.URL) 438 + .withUrl(url) 439 + .buildOrThrow(); 440 + 441 + const card2 = new CardBuilder() 442 + .withCuratorId(curator2.value) 443 + .withType(CardTypeEnum.URL) 444 + .withUrl(url) 445 + .buildOrThrow(); 446 + 447 + const card3 = new CardBuilder() 448 + .withCuratorId(curator3.value) 449 + .withType(CardTypeEnum.URL) 450 + .withUrl(url) 451 + .buildOrThrow(); 452 + 453 + // Add cards to libraries with different counts 454 + card1.addToLibrary(curator1); 455 + 456 + card2.addToLibrary(curator2); 457 + card2.addToLibrary(curator1); // card2 has 2 library memberships 458 + 459 + card3.addToLibrary(curator3); 460 + card3.addToLibrary(curator1); // card3 has 3 library memberships 461 + card3.addToLibrary(curator2); 462 + 463 + await cardRepository.save(card1); 464 + await cardRepository.save(card2); 465 + await cardRepository.save(card3); 466 + 467 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 468 + page: 1, 469 + limit: 10, 470 + sortBy: CardSortField.LIBRARY_COUNT, 471 + sortOrder: SortOrder.DESC, 472 + }); 473 + 474 + // Should return all library memberships, but sorted by the card's library count 475 + expect(result.items.length).toBeGreaterThan(0); 476 + 477 + // Group by card ID to check sorting 478 + const cardGroups = new Map<string, any[]>(); 479 + result.items.forEach((item) => { 480 + const cardId = item.card.id; 481 + if (!cardGroups.has(cardId)) { 482 + cardGroups.set(cardId, []); 483 + } 484 + cardGroups.get(cardId)!.push(item); 485 + }); 486 + 487 + // Get the first occurrence of each card to check library count ordering 488 + const uniqueCards = Array.from(cardGroups.entries()).map( 489 + ([cardId, items]) => ({ 490 + cardId, 491 + libraryCount: items[0]!.card.libraryCount, 492 + }), 493 + ); 494 + 495 + // Should be sorted by library count descending 496 + for (let i = 0; i < uniqueCards.length - 1; i++) { 497 + expect(uniqueCards[i]!.libraryCount).toBeGreaterThanOrEqual( 498 + uniqueCards[i + 1]!.libraryCount, 499 + ); 500 + } 501 + }); 502 + 503 + it('should sort by libraryCount in ascending order when specified', async () => { 504 + const testUrl = 'https://example.com/sort-library-count-asc-test'; 505 + const url = URL.create(testUrl).unwrap(); 506 + 507 + // Create cards with different library counts 508 + const card1 = new CardBuilder() 509 + .withCuratorId(curator1.value) 510 + .withType(CardTypeEnum.URL) 511 + .withUrl(url) 512 + .buildOrThrow(); 513 + 514 + const card2 = new CardBuilder() 515 + .withCuratorId(curator2.value) 516 + .withType(CardTypeEnum.URL) 517 + .withUrl(url) 518 + .buildOrThrow(); 519 + 520 + // card1 has 1 library membership, card2 has 2 521 + card1.addToLibrary(curator1); 522 + card2.addToLibrary(curator2); 523 + card2.addToLibrary(curator1); 524 + 525 + await cardRepository.save(card1); 526 + await cardRepository.save(card2); 527 + 528 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 529 + page: 1, 530 + limit: 10, 531 + sortBy: CardSortField.LIBRARY_COUNT, 532 + sortOrder: SortOrder.ASC, 533 + }); 534 + 535 + expect(result.items.length).toBeGreaterThan(0); 536 + 537 + // Group by card ID and check ascending order 538 + const cardGroups = new Map<string, any[]>(); 539 + result.items.forEach((item) => { 540 + const cardId = item.card.id; 541 + if (!cardGroups.has(cardId)) { 542 + cardGroups.set(cardId, []); 543 + } 544 + cardGroups.get(cardId)!.push(item); 545 + }); 546 + 547 + const uniqueCards = Array.from(cardGroups.entries()).map( 548 + ([cardId, items]) => ({ 549 + cardId, 550 + libraryCount: items[0]!.card.libraryCount, 551 + }), 552 + ); 553 + 554 + // Should be sorted by library count ascending 555 + for (let i = 0; i < uniqueCards.length - 1; i++) { 556 + expect(uniqueCards[i]!.libraryCount).toBeLessThanOrEqual( 557 + uniqueCards[i + 1]!.libraryCount, 558 + ); 559 + } 284 560 }); 285 561 }); 286 562
+74 -55
src/webapp/app/(dashboard)/error.tsx
··· 16 16 import DarkBG from '@/assets/semble-bg-dark.png'; 17 17 import Link from 'next/link'; 18 18 import { BiRightArrowAlt } from 'react-icons/bi'; 19 - import { useColorScheme } from '@mantine/hooks'; 20 19 21 20 export default function Error() { 22 - const colorScheme = useColorScheme(); 21 + return ( 22 + <> 23 + {/* light mode background */} 24 + <BackgroundImage 25 + src={BG.src} 26 + darkHidden 27 + h={'100svh'} 28 + pos={'fixed'} 29 + top={0} 30 + left={0} 31 + style={{ zIndex: 102 }} 32 + > 33 + <Content /> 34 + </BackgroundImage> 35 + 36 + {/* dark mode background */} 37 + <BackgroundImage 38 + src={DarkBG.src} 39 + lightHidden 40 + h={'100svh'} 41 + pos={'fixed'} 42 + top={0} 43 + left={0} 44 + style={{ zIndex: 102 }} 45 + > 46 + <Content /> 47 + </BackgroundImage> 48 + </> 49 + ); 50 + } 23 51 52 + function Content() { 24 53 return ( 25 - <BackgroundImage 26 - src={colorScheme === 'dark' ? DarkBG.src : BG.src} 27 - h={'100svh'} 28 - pos={'fixed'} 29 - top={0} 30 - left={0} 31 - style={{ zIndex: 102 }} 32 - > 33 - <Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}> 34 - <Container size={'xl'} p={'md'} my={'auto'}> 54 + <Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}> 55 + <Container size={'xl'} p={'md'} my={'auto'}> 56 + <Stack> 57 + <Stack align="center" gap={'xs'}> 58 + <Image 59 + src={SembleLogo.src} 60 + alt="Semble logo" 61 + w={48} 62 + h={64.5} 63 + mx={'auto'} 64 + /> 65 + <Badge size="sm">Alpha</Badge> 66 + </Stack> 67 + 35 68 <Stack> 36 - <Stack align="center" gap={'xs'}> 37 - <Image 38 - src={SembleLogo.src} 39 - alt="Semble logo" 40 - w={48} 41 - h={64.5} 42 - mx={'auto'} 43 - /> 44 - <Badge size="sm">Alpha</Badge> 45 - </Stack> 46 - 47 - <Stack> 48 - <Text fz={'h1'} fw={600} ta={'center'}> 49 - A social knowledge network for researchers 50 - </Text> 51 - <Text 52 - fz={'h3'} 53 - fw={600} 54 - c={colorScheme === 'dark' ? '#1e4dd9' : '#1F6144'} 55 - ta={'center'} 56 - > 57 - Follow your peersโ€™ research trails. Surface and discover new 58 - connections. Built on ATProto so you own your data. 59 - </Text> 60 - </Stack> 69 + <Text fz={'h1'} fw={600} ta={'center'}> 70 + A social knowledge network for researchers 71 + </Text> 72 + <Text fz={'h3'} fw={600} c={'#1e4dd9'} ta={'center'} lightHidden> 73 + Follow your peersโ€™ research trails. Surface and discover new 74 + connections. Built on ATProto so you own your data. 75 + </Text> 76 + <Text fz={'h3'} fw={600} c={'#1F6144'} ta={'center'} darkHidden> 77 + Follow your peersโ€™ research trails. Surface and discover new 78 + connections. Built on ATProto so you own your data. 79 + </Text> 80 + </Stack> 61 81 62 - <Group justify="center" gap="md" mt={'lg'}> 63 - <Button component={Link} href="/signup" size="lg"> 64 - Sign up 65 - </Button> 82 + <Group justify="center" gap="md" mt={'lg'}> 83 + <Button component={Link} href="/signup" size="lg"> 84 + Sign up 85 + </Button> 66 86 67 - <Button 68 - component={Link} 69 - href="/login" 70 - size="lg" 71 - color="dark" 72 - rightSection={<BiRightArrowAlt size={22} />} 73 - > 74 - Log in 75 - </Button> 76 - </Group> 77 - </Stack> 78 - </Container> 79 - </Center> 80 - </BackgroundImage> 87 + <Button 88 + component={Link} 89 + href="/login" 90 + size="lg" 91 + color="var(--mantine-color-dark-filled)" 92 + rightSection={<BiRightArrowAlt size={22} />} 93 + > 94 + Log in 95 + </Button> 96 + </Group> 97 + </Stack> 98 + </Container> 99 + </Center> 81 100 ); 82 101 }
+15
src/webapp/app/bookmarklet/layout.tsx
··· 1 + import type { Metadata } from 'next'; 2 + 3 + export const metadata: Metadata = { 4 + title: 'Semble bookmarklet', 5 + description: 6 + 'Learn how to add our bookmarklet to your browser to quickly open any webpage in Semble.', 7 + }; 8 + 9 + interface Props { 10 + children: React.ReactNode; 11 + } 12 + 13 + export default function Layout(props: Props) { 14 + return props.children; 15 + }
+143
src/webapp/app/bookmarklet/page.tsx
··· 1 + 'use client'; 2 + 3 + import { 4 + Container, 5 + Title, 6 + Text, 7 + Stack, 8 + Button, 9 + Code, 10 + Alert, 11 + Box, 12 + Badge, 13 + Image, 14 + Group, 15 + Anchor, 16 + CopyButton, 17 + } from '@mantine/core'; 18 + import SembleLogo from '@/assets/semble-logo.svg'; 19 + import Link from 'next/link'; 20 + 21 + export default function BookmarkletPage() { 22 + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000'; 23 + 24 + const bookmarkletCode = `javascript:(function(){ 25 + const currentUrl = window.location.href; 26 + const sembleUrl = '${appUrl}/url?id=' + currentUrl; 27 + window.open(sembleUrl, '_blank'); 28 + })();`; 29 + 30 + // Create the bookmarklet link using dangerouslySetInnerHTML to bypass React's security check 31 + const createBookmarkletLink = () => { 32 + return { 33 + __html: `<a href="${bookmarkletCode}" style="text-decoration: none; padding: 8px 16px; background-color: var(--mantine-color-tangerine-6); color: white; border-radius: 100px; display: inline-flex; align-items: center; gap: 8px; font-weight: 600;"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"/></svg>Open in Semble</a>`, 34 + }; 35 + }; 36 + 37 + return ( 38 + <Container size="sm" p="md"> 39 + <Stack gap="xl"> 40 + <Stack gap="xs" align="center"> 41 + <Stack align="center" gap={'xs'}> 42 + <Anchor component={Link} href={'/'}> 43 + <Image 44 + src={SembleLogo.src} 45 + alt="Semble logo" 46 + w={48} 47 + h={64.5} 48 + mx={'auto'} 49 + /> 50 + <Badge size="sm">Alpha</Badge> 51 + </Anchor> 52 + </Stack> 53 + <Stack gap={'xs'} align="center"> 54 + <Title order={1}>Semble Bookmarklet</Title> 55 + <Title 56 + order={2} 57 + size="xl" 58 + c="dimmed" 59 + fw={600} 60 + maw={500} 61 + ta={'center'} 62 + > 63 + Add this bookmarklet to your browser to quickly open any webpage 64 + in Semble. 65 + </Title> 66 + </Stack> 67 + </Stack> 68 + 69 + <Alert title="How to install" color="grape"> 70 + <Stack gap="sm"> 71 + <Group gap={'xs'}> 72 + <Badge size="md" color="grape" circle> 73 + 1 74 + </Badge> 75 + <Text fw={500} c="grape"> 76 + Copy the bookmarklet code below or drag the button to your 77 + bookmarks bar 78 + </Text> 79 + </Group> 80 + <Group gap={'xs'}> 81 + <Badge size="md" color="grape" circle> 82 + 2 83 + </Badge> 84 + 85 + <Text fw={500} c={'grape'}> 86 + { 87 + "When you're on any webpage, click the bookmarklet to open it in Semble" 88 + } 89 + </Text> 90 + </Group> 91 + </Stack> 92 + </Alert> 93 + 94 + <Stack gap="md"> 95 + <Stack gap={'xs'}> 96 + <Title order={3}>Method 1: Drag to Bookmarks Bar</Title> 97 + <Text c="dimmed" fw={500}> 98 + {"Drag this button directly to your browser's bookmarks bar:"} 99 + </Text> 100 + </Stack> 101 + <Group> 102 + <Box dangerouslySetInnerHTML={createBookmarkletLink()} /> 103 + </Group> 104 + </Stack> 105 + 106 + <Stack gap="md"> 107 + <Stack gap={'xs'}> 108 + <Title order={3}>Method 2: Copy Code</Title> 109 + <Text c="dimmed" fw={500}> 110 + Copy this code and create a new bookmark with it as the URL: 111 + </Text> 112 + </Stack> 113 + <Box pos="relative"> 114 + <Code 115 + block 116 + p="md" 117 + style={{ 118 + wordBreak: 'break-all', 119 + whiteSpace: 'pre-wrap', 120 + fontSize: '12px', 121 + }} 122 + > 123 + {bookmarkletCode} 124 + </Code> 125 + <CopyButton value={bookmarkletCode}> 126 + {({ copied, copy }) => ( 127 + <Button 128 + color="dark" 129 + pos={'absolute'} 130 + top={12} 131 + right={12} 132 + onClick={copy} 133 + > 134 + {copied ? 'Copied!' : 'Copy'} 135 + </Button> 136 + )} 137 + </CopyButton> 138 + </Box> 139 + </Stack> 140 + </Stack> 141 + </Container> 142 + ); 143 + }
+1 -1
src/webapp/app/layout.tsx
··· 26 26 {...mantineHtmlProps} 27 27 > 28 28 <head> 29 - <ColorSchemeScript forceColorScheme="light" /> 29 + <ColorSchemeScript defaultColorScheme="auto" /> 30 30 </head> 31 31 <body className={GlobalStyles.main}> 32 32 <Providers>{children}</Providers>
+182 -152
src/webapp/app/page.tsx
··· 30 30 import TangledIcon from '@/assets/icons/tangled-icon.svg'; 31 31 import SembleLogo from '@/assets/semble-logo.svg'; 32 32 import Link from 'next/link'; 33 - import { useColorScheme } from '@mantine/hooks'; 34 33 35 34 export default function Home() { 36 - const colorScheme = useColorScheme(); 35 + return ( 36 + <> 37 + {/* light mode background */} 38 + <BackgroundImage src={BG.src} darkHidden h="100svh"> 39 + <Content /> 40 + </BackgroundImage> 41 + 42 + {/* dark mode background */} 43 + <BackgroundImage src={DarkBG.src} lightHidden h="100svh"> 44 + <Content /> 45 + </BackgroundImage> 46 + </> 47 + ); 48 + } 37 49 50 + function Content() { 38 51 return ( 39 - <BackgroundImage 40 - src={colorScheme === 'dark' ? DarkBG.src : BG.src} 41 - h={'100svh'} 42 - > 52 + <> 43 53 <script async src="https://tally.so/widgets/embed.js" /> 44 - <Container size={'xl'} p={'md'} my={'auto'}> 54 + <Container size="xl" p="md" my="auto"> 45 55 <Group justify="space-between"> 46 56 <Stack gap={6} align="center"> 47 - <Image src={SembleLogo.src} alt="Semble logo" w={30} h={'auto'} /> 57 + <Image src={SembleLogo.src} alt="Semble logo" w={30} h="auto" /> 48 58 <Badge size="sm">Alpha</Badge> 49 59 </Stack> 50 60 <Button ··· 59 69 </Button> 60 70 </Group> 61 71 </Container> 62 - <Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}> 63 - <Container size={'xl'} p={'md'} my={'auto'}> 64 - <Stack align="center" gap={'5rem'}> 65 - <Stack gap={'xs'} align="center" maw={550} mx={'auto'}> 66 - <Title order={1} fw={600} fz={'3rem'} ta={'center'}> 72 + 73 + <Center h="100svh" py={{ base: '2rem', xs: '5rem' }}> 74 + <Container size="xl" p="md" my="auto"> 75 + <Stack align="center" gap="5rem"> 76 + <Stack gap="xs" align="center" maw={550} mx="auto"> 77 + <Title order={1} fw={600} fz="3rem" ta="center"> 67 78 A social knowledge network for researchers 68 79 </Title> 80 + 81 + {/* light mode subtitle */} 69 82 <Title 70 83 order={2} 71 84 fw={600} 72 - fz={'xl'} 73 - c={colorScheme === 'dark' ? '#1e4dd9' : '#1F6144'} 74 - ta={'center'} 85 + fz="xl" 86 + c="#1F6144" 87 + ta="center" 88 + darkHidden 89 + > 90 + Follow your peersโ€™ research trails. Surface and discover new 91 + connections. Built on ATProto so you own your data. 92 + </Title> 93 + 94 + {/* dark mode subtitle */} 95 + <Title 96 + order={2} 97 + fw={600} 98 + fz="xl" 99 + c="#1e4dd9" 100 + ta="center" 101 + lightHidden 75 102 > 76 103 Follow your peersโ€™ research trails. Surface and discover new 77 104 connections. Built on ATProto so you own your data. 78 105 </Title> 79 106 80 107 {process.env.VERCEL_ENV !== 'production' && ( 81 - <Group gap="md" mt={'lg'}> 108 + <Group gap="md" mt="lg"> 82 109 <Button component={Link} href="/signup" size="lg"> 83 110 Sign up 84 111 </Button> ··· 87 114 component={Link} 88 115 href="/login" 89 116 size="lg" 90 - color="dark" 117 + color="var(--mantine-color-dark-filled)" 91 118 rightSection={<BiRightArrowAlt size={22} />} 92 119 > 93 120 Log in ··· 101 128 spacing={{ base: 'xl' }} 102 129 mt={{ base: '1rem', xs: '5rem' }} 103 130 > 104 - <Stack gap={'xs'}> 131 + <Stack gap="xs"> 105 132 <Image src={CurateIcon.src} alt="Curate icon" w={28} /> 106 133 <Text> 107 - <Text fw={600} fz={'lg'} span> 134 + <Text fw={600} fz="lg" span> 108 135 Curate your research trails. 109 136 </Text>{' '} 110 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 137 + <Text fw={500} fz="lg" c="dark.2" span> 111 138 Collect interesting links, add notes, and organize them into 112 139 shareable collections. Build trails others can explore and 113 140 extend. 114 141 </Text> 115 142 </Text> 116 143 </Stack> 117 - <Stack gap={'xs'}> 144 + <Stack gap="xs"> 118 145 <Image src={CommunityIcon.src} alt="Community icon" w={28} /> 119 146 <Text> 120 - <Text fw={600} fz={'lg'} span> 147 + <Text fw={600} fz="lg" span> 121 148 Connect with peers. 122 149 </Text>{' '} 123 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 150 + <Text fw={500} fz="lg" c="dark.2" span> 124 151 See what your peers are sharing and find new collaborators 125 152 with shared interests. Experience research rabbit holes, 126 153 together. 127 154 </Text> 128 155 </Text> 129 156 </Stack> 130 - <Stack gap={'xs'}> 157 + <Stack gap="xs"> 131 158 <Image src={DBIcon.src} alt="Database icon" w={28} /> 132 159 <Text> 133 - <Text fw={600} fz={'lg'} span> 160 + <Text fw={600} fz="lg" span> 134 161 Own your data. 135 162 </Text>{' '} 136 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 163 + <Text fw={500} fz="lg" c="dark.2" span> 137 164 Built on ATProto, new apps will come to you. No more 138 165 rebuilding your social graph and data when apps pivot and 139 166 shut down. 140 167 </Text> 141 168 </Text> 142 169 </Stack> 143 - <Stack gap={'xs'}> 170 + <Stack gap="xs"> 144 171 <Image src={BigPictureIcon.src} alt="Big picture icon" w={28} /> 145 172 <Text> 146 - <Text fw={600} fz={'lg'} span> 173 + <Text fw={600} fz="lg" span> 147 174 See the bigger picture. 148 175 </Text>{' '} 149 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 176 + <Text fw={500} fz="lg" c="dark.2" span> 150 177 Find relevant research based on your network. Get the extra 151 178 context that matters before you dive into a long read. 152 179 </Text> ··· 154 181 </Stack> 155 182 </SimpleGrid> 156 183 157 - <Box 158 - component="footer" 159 - px={'md'} 160 - py={'xs'} 161 - mt={'xl'} 162 - pos={'relative'} 163 - > 164 - <Stack align="center" gap={'xs'}> 165 - <Group gap="0"> 166 - <ActionIcon 167 - component="a" 168 - href="https://bsky.app/profile/cosmik.network" 169 - target="_blank" 170 - variant="subtle" 171 - color={'dark.2'} 172 - radius={'xl'} 173 - size={'xl'} 174 - m={0} 175 - > 176 - <FaBluesky size={22} /> 177 - </ActionIcon> 178 - <ActionIcon 179 - component="a" 180 - href="https://tangled.org/@cosmik.network/semble" 181 - target="_blank" 182 - variant="subtle" 183 - color={'dark.2'} 184 - radius={'xl'} 185 - size={'xl'} 186 - > 187 - <Image 188 - src={TangledIcon.src} 189 - alt="Tangled logo" 190 - w={'auto'} 191 - h={22} 192 - /> 193 - </ActionIcon> 194 - <ActionIcon 195 - component="a" 196 - href="https://github.com/cosmik-network" 197 - target="_blank" 198 - variant="subtle" 199 - color={'dark.2'} 200 - radius={'xl'} 201 - size={'xl'} 202 - > 203 - <FaGithub size={22} /> 204 - </ActionIcon> 205 - <ActionIcon 206 - component="a" 207 - href="https://discord.gg/SHvvysb73e" 208 - target="_blank" 209 - variant="subtle" 210 - color={'dark.2'} 211 - radius={'xl'} 212 - size={'xl'} 213 - > 214 - <FaDiscord size={22} /> 215 - </ActionIcon> 216 - </Group> 217 - <Button 218 - component="a" 219 - href="https://blog.cosmik.network" 220 - target="_blank" 221 - variant="light" 222 - color="dark.1" 223 - fw={600} 224 - rightSection={<RiArrowRightUpLine />} 225 - > 226 - Follow our blog for updates 227 - </Button> 228 - <Stack align="center" gap={'0'}> 229 - <Text c="dark.1" fw={600} ta="center"> 230 - Made by &nbsp; 231 - <Anchor 232 - href="https://cosmik.network/" 233 - target="_blank" 234 - style={{ verticalAlign: 'middle' }} 235 - > 236 - <Box 237 - component="span" 238 - display="inline-flex" 239 - style={{ verticalAlign: 'middle' }} 240 - > 241 - <Image 242 - src={ 243 - colorScheme === 'dark' 244 - ? CosmikLogoWhite.src 245 - : CosmikLogo.src 246 - } 247 - alt="Cosmik logo" 248 - w={92} 249 - h={28.4} 250 - /> 251 - </Box> 252 - </Anchor> 253 - &nbsp;&nbsp; 254 - <Text c="dark.1" fw={600} span> 255 - with support from&nbsp; 256 - <Anchor 257 - href="https://www.openphilanthropy.org/" 258 - target="_blank" 259 - c="dark.2" 260 - fw={600} 261 - > 262 - Open Philanthropy 263 - </Anchor>{' '} 264 - and{' '} 265 - <Anchor 266 - href="https://astera.org/" 267 - target="_blank" 268 - c="dark.2" 269 - fw={600} 270 - > 271 - Astera 272 - </Anchor> 273 - </Text> 274 - </Text> 275 - </Stack> 276 - </Stack> 277 - </Box> 184 + <Footer /> 278 185 </Stack> 279 186 </Container> 280 187 </Center> 281 - </BackgroundImage> 188 + </> 189 + ); 190 + } 191 + 192 + function Footer() { 193 + return ( 194 + <Box component="footer" px="md" py="xs" mt="xl" pos="relative"> 195 + <Stack align="center" gap="xs"> 196 + <Group gap="0"> 197 + <ActionIcon 198 + component="a" 199 + href="https://bsky.app/profile/cosmik.network" 200 + target="_blank" 201 + variant="subtle" 202 + color="dark.2" 203 + radius="xl" 204 + size="xl" 205 + m={0} 206 + > 207 + <FaBluesky size={22} /> 208 + </ActionIcon> 209 + <ActionIcon 210 + component="a" 211 + href="https://tangled.org/@cosmik.network/semble" 212 + target="_blank" 213 + variant="subtle" 214 + color="dark.2" 215 + radius="xl" 216 + size="xl" 217 + > 218 + <Image src={TangledIcon.src} alt="Tangled logo" w="auto" h={22} /> 219 + </ActionIcon> 220 + <ActionIcon 221 + component="a" 222 + href="https://github.com/cosmik-network" 223 + target="_blank" 224 + variant="subtle" 225 + color="dark.2" 226 + radius="xl" 227 + size="xl" 228 + > 229 + <FaGithub size={22} /> 230 + </ActionIcon> 231 + <ActionIcon 232 + component="a" 233 + href="https://discord.gg/SHvvysb73e" 234 + target="_blank" 235 + variant="subtle" 236 + color="dark.2" 237 + radius="xl" 238 + size="xl" 239 + > 240 + <FaDiscord size={22} /> 241 + </ActionIcon> 242 + </Group> 243 + 244 + <Button 245 + component="a" 246 + href="https://blog.cosmik.network" 247 + target="_blank" 248 + variant="light" 249 + color="dark.1" 250 + fw={600} 251 + rightSection={<RiArrowRightUpLine />} 252 + > 253 + Follow our blog for updates 254 + </Button> 255 + 256 + <Stack align="center" gap="0"> 257 + <Text c="dark.1" fw={600} ta="center"> 258 + Made by &nbsp; 259 + <Anchor 260 + href="https://cosmik.network/" 261 + target="_blank" 262 + style={{ verticalAlign: 'middle' }} 263 + > 264 + <Box 265 + component="span" 266 + display="inline-flex" 267 + style={{ verticalAlign: 'middle' }} 268 + > 269 + {/* light logo */} 270 + <Image 271 + src={CosmikLogo.src} 272 + alt="Cosmik logo" 273 + w={92} 274 + h={28.4} 275 + darkHidden 276 + /> 277 + {/* dark logo */} 278 + <Image 279 + src={CosmikLogoWhite.src} 280 + alt="Cosmik logo white" 281 + w={92} 282 + h={28.4} 283 + lightHidden 284 + /> 285 + </Box> 286 + </Anchor> 287 + &nbsp;&nbsp; 288 + <Text c="dark.1" fw={600} span> 289 + with support from&nbsp; 290 + <Anchor 291 + href="https://www.openphilanthropy.org/" 292 + target="_blank" 293 + c="dark.2" 294 + fw={600} 295 + > 296 + Open Philanthropy 297 + </Anchor>{' '} 298 + and{' '} 299 + <Anchor 300 + href="https://astera.org/" 301 + target="_blank" 302 + c="dark.2" 303 + fw={600} 304 + > 305 + Astera 306 + </Anchor> 307 + </Text> 308 + </Text> 309 + </Stack> 310 + </Stack> 311 + </Box> 282 312 ); 283 313 }
+1 -3
src/webapp/components/navigation/bottomBarItem/BottomBarItem.tsx
··· 1 1 import { IconType } from 'react-icons/lib'; 2 2 import { ActionIcon } from '@mantine/core'; 3 - import { usePathname } from 'next/navigation'; 4 3 import Link from 'next/link'; 5 4 import { ReactElement, isValidElement } from 'react'; 6 - import { useColorScheme } from '@mantine/hooks'; 5 + import { usePathname } from 'next/navigation'; 7 6 8 7 interface Props { 9 8 href: string; ··· 11 10 } 12 11 13 12 export default function BottomBarItem(props: Props) { 14 - const colorScheme = useColorScheme(); 15 13 const pathname = usePathname(); 16 14 const isActive = pathname === props.href; 17 15
+1 -1
src/webapp/components/navigation/guestNavbar/GuestNavbar.tsx
··· 46 46 <Button 47 47 component={Link} 48 48 href="/login" 49 - color="dark" 49 + color="var(--mantine-color-dark-filled)" 50 50 rightSection={<BiRightArrowAlt size={22} />} 51 51 > 52 52 Log in
+2 -2
src/webapp/features/auth/components/loginForm/LoginForm.tsx
··· 177 177 <Button 178 178 type="submit" 179 179 size="lg" 180 - color="dark" 180 + color="var(--mantine-color-dark-filled)" 181 181 fullWidth 182 182 rightSection={<BiRightArrowAlt size={22} />} 183 183 loading={isLoading} ··· 226 226 <Button 227 227 type="submit" 228 228 size="lg" 229 - color="dark" 229 + color="var(--mantine-color-dark-filled)" 230 230 fullWidth 231 231 rightSection={<BiRightArrowAlt size={22} />} 232 232 loading={isLoading}
+1 -1
src/webapp/features/auth/components/signUpForm/SignUpForm.tsx
··· 9 9 href="https://bsky.app/" 10 10 target="_blank" 11 11 size="lg" 12 - color="dark" 12 + color="var(--mantine-color-dark-filled)" 13 13 leftSection={<FaBluesky size={22} />} 14 14 > 15 15 Sign up on Bluesky
+1 -1
src/webapp/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
··· 127 127 </Anchor> 128 128 </Tooltip> 129 129 {props.title && ( 130 - <Text fw={500} lineClamp={1}> 130 + <Text fw={500} lineClamp={1} c="var(--mantine-color-bright)"> 131 131 {props.title} 132 132 </Text> 133 133 )}
+1 -1
src/webapp/features/cards/components/urlCard/UrlCard.tsx
··· 67 67 </Anchor> 68 68 </Tooltip> 69 69 {props.cardContent.title && ( 70 - <Text fw={500} lineClamp={2}> 70 + <Text fw={500} lineClamp={2} c={'var(--mantine-color-bright)'}> 71 71 {props.cardContent.title} 72 72 </Text> 73 73 )}
+3 -2
src/webapp/features/cards/lib/cardKeys.ts
··· 2 2 all: () => ['cards'] as const, 3 3 card: (id: string) => [...cardKeys.all(), id] as const, 4 4 byUrl: (url: string) => [...cardKeys.all(), url] as const, 5 - mine: () => [...cardKeys.all(), 'mine'] as const, 5 + mine: (limit?: number) => [...cardKeys.all(), 'mine', limit] as const, 6 6 search: (query: string) => [...cardKeys.all(), 'search', query], 7 7 bySembleUrl: (url: string) => [...cardKeys.all(), url], 8 8 libraries: (id: string) => [...cardKeys.all(), 'libraries', id], 9 - infinite: (didOrHandle?: string) => [ 9 + infinite: (didOrHandle?: string, limit?: number) => [ 10 10 ...cardKeys.all(), 11 11 'infinite', 12 12 didOrHandle, 13 + limit, 13 14 ], 14 15 };
+1 -1
src/webapp/features/cards/lib/queries/useCards.tsx
··· 11 11 const limit = props?.limit ?? 16; 12 12 13 13 const cards = useSuspenseInfiniteQuery({ 14 - queryKey: cardKeys.infinite(props.didOrHandle), 14 + queryKey: cardKeys.infinite(props.didOrHandle, props.limit), 15 15 initialPageParam: 1, 16 16 queryFn: ({ pageParam = 1 }) => { 17 17 return getUrlCards(props.didOrHandle, {
+1 -1
src/webapp/features/cards/lib/queries/useMyCards.tsx
··· 10 10 const limit = props?.limit ?? 16; 11 11 12 12 const myCards = useSuspenseInfiniteQuery({ 13 - queryKey: cardKeys.mine(), 13 + queryKey: cardKeys.mine(props?.limit), 14 14 initialPageParam: 1, 15 15 queryFn: ({ pageParam = 1 }) => { 16 16 return getMyUrlCards({ page: pageParam, limit });
+2 -2
src/webapp/features/collections/components/collectionCard/CollectionCard.tsx
··· 34 34 > 35 35 <Stack justify="space-between" h={'100%'}> 36 36 <Stack gap={0}> 37 - <Text fw={500} lineClamp={1}> 37 + <Text fw={500} lineClamp={1} c={'var(--mantine-color-bright)'}> 38 38 {collection.name} 39 39 </Text> 40 40 {collection.description && ( ··· 58 58 size={'sm'} 59 59 /> 60 60 61 - <Text c={'dark'} fw={500} span> 61 + <Text fw={500} c={'var(--mantine-color-bright)'} span> 62 62 {collection.author.name} 63 63 </Text> 64 64 </Group>
+39
src/webapp/features/collections/components/collectionNavItem/CollectionNavItem.module.css
··· 1 + /* navlink inactive */ 2 + .navLink { 3 + color: var(--mantine-color-gray-6); 4 + 5 + @mixin dark { 6 + color: var(--mantine-color-gray-4); 7 + } 8 + } 9 + 10 + /* navlink active */ 11 + .navLinkActive { 12 + color: var(--mantine-color-dark-filled); 13 + 14 + @mixin dark { 15 + color: var(--mantine-color-white); 16 + } 17 + } 18 + 19 + /* badge inactive */ 20 + .badge { 21 + background-color: var(--mantine-color-gray-light); 22 + color: var(--mantine-color-gray-light-color); 23 + 24 + @mixin dark { 25 + color: var(--mantine-color-gray-4); 26 + background-color: var(--mantine-color-gray-light); 27 + } 28 + } 29 + 30 + /* badge active */ 31 + .badgeActive { 32 + background-color: var(--mantine-color-dark-filled); 33 + color: var(--mantine-color-white); 34 + 35 + @mixin dark { 36 + background-color: var(--mantine-color-gray-6); 37 + color: var(--mantine-color-white); 38 + } 39 + }
+5 -7
src/webapp/features/collections/components/collectionNavItem/CollectionNavItem.tsx
··· 1 1 import { useNavbarContext } from '@/providers/navbar'; 2 2 import { Badge, NavLink } from '@mantine/core'; 3 - import { useColorScheme } from '@mantine/hooks'; 4 3 import Link from 'next/link'; 5 4 import { usePathname } from 'next/navigation'; 5 + import styles from './CollectionNavItem.module.css'; 6 6 7 7 interface Props { 8 8 name: string; ··· 12 12 13 13 export default function CollectionNavItem(props: Props) { 14 14 const { toggleMobile } = useNavbarContext(); 15 - const colorScheme = useColorScheme(); 16 15 const pathname = usePathname(); 17 16 const isActive = pathname === props.url; 18 17 ··· 22 21 href={props.url} 23 22 label={props.name} 24 23 variant="subtle" 25 - c={isActive ? `${colorScheme === 'dark' ? 'white' : 'dark'}` : 'gray'} 24 + className={ 25 + isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink 26 + } 26 27 onClick={toggleMobile} 27 28 rightSection={ 28 29 props.cardCount > 0 ? ( 29 30 <Badge 30 - variant={isActive ? 'filled' : 'light'} 31 - color={ 32 - isActive ? `${colorScheme === 'dark' ? 'gray' : 'dark'}` : 'gray' 33 - } 31 + className={isActive ? styles.badgeActive : styles.badge} 34 32 circle 35 33 > 36 34 {props.cardCount}
+1 -1
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
··· 68 68 </Stack> 69 69 70 70 <Group gap={'xs'}> 71 - <Text fw={600} c="gray.7"> 71 + <Text fw={600} c="gray"> 72 72 By 73 73 </Text> 74 74 <Group gap={5}>
+7 -2
src/webapp/features/collections/lib/collectionKeys.ts
··· 1 1 export const collectionKeys = { 2 2 all: () => ['collections'] as const, 3 3 collection: (id: string) => [...collectionKeys.all(), id] as const, 4 - mine: () => [...collectionKeys.all(), 'mine'] as const, 4 + mine: (limit?: number) => [...collectionKeys.all(), 'mine', limit] as const, 5 5 search: (query: string) => [...collectionKeys.all(), 'search', query], 6 6 bySembleUrl: (url: string) => [...collectionKeys.all(), url], 7 - infinite: (id?: string) => [...collectionKeys.all(), 'infinite', id], 7 + infinite: (id?: string, limit?: number) => [ 8 + ...collectionKeys.all(), 9 + 'infinite', 10 + id, 11 + limit, 12 + ], 8 13 };
+1 -1
src/webapp/features/collections/lib/mutations/useCreateCollection.tsx
··· 14 14 // Do UI related things like redirects or showing toast notifications in mutate callbacks. If the user navigated away from the current screen before the mutation finished, those will purposefully not fire 15 15 // https://tkdodo.eu/blog/mastering-mutations-in-react-query#some-callbacks-might-not-fire 16 16 onSuccess: () => { 17 - queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 17 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 18 18 queryClient.refetchQueries({ queryKey: collectionKeys.mine() }); 19 19 }, 20 20 });
+1 -1
src/webapp/features/collections/lib/queries/useCollection.tsx
··· 12 12 const limit = props.limit ?? 20; 13 13 14 14 return useSuspenseInfiniteQuery({ 15 - queryKey: collectionKeys.infinite(props.rkey), 15 + queryKey: collectionKeys.infinite(props.rkey, props.limit), 16 16 initialPageParam: 1, 17 17 queryFn: ({ pageParam }) => 18 18 getCollectionPageByAtUri({
+1 -1
src/webapp/features/collections/lib/queries/useCollections.tsx
··· 11 11 const limit = props?.limit ?? 15; 12 12 13 13 return useSuspenseInfiniteQuery({ 14 - queryKey: collectionKeys.infinite(props.didOrHandle), 14 + queryKey: collectionKeys.infinite(props.didOrHandle, props.limit), 15 15 initialPageParam: 1, 16 16 queryFn: ({ pageParam }) => 17 17 getCollections(props.didOrHandle, {
+1 -1
src/webapp/features/collections/lib/queries/useMyCollections.tsx
··· 10 10 const limit = props?.limit ?? 15; 11 11 12 12 return useSuspenseInfiniteQuery({ 13 - queryKey: collectionKeys.mine(), 13 + queryKey: collectionKeys.mine(props?.limit), 14 14 initialPageParam: 1, 15 15 queryFn: ({ pageParam }) => getMyCollections({ limit, page: pageParam }), 16 16 getNextPageParam: (lastPage) => {
+9
src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.module.css
··· 1 + .root { 2 + @mixin light { 3 + background-color: var(--mantine-color-gray-1); 4 + } 5 + 6 + @mixin dark { 7 + background-color: var(--mantine-color-dark-4); 8 + } 9 + }
+18 -19
src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx
··· 1 - 'use client'; 2 - 3 1 import { 4 2 Anchor, 5 3 Avatar, 6 4 Card, 7 5 Group, 8 6 Menu, 7 + MenuDropdown, 8 + MenuItem, 9 + MenuTarget, 9 10 ScrollArea, 10 11 Stack, 11 12 Text, ··· 13 14 import { FeedItem, Collection } from '@/api-client'; 14 15 import { Fragment } from 'react'; 15 16 import Link from 'next/link'; 17 + import styles from './FeedActivityStatus.module.css'; 16 18 import { getRelativeTime } from '@/lib/utils/time'; 17 19 import { getRecordKey } from '@/lib/utils/atproto'; 18 20 import { sanitizeText } from '@/lib/utils/text'; 19 - import { useColorScheme } from '@mantine/hooks'; 20 - import { BiCollection } from 'react-icons/bi'; 21 21 22 22 interface Props { 23 23 user: FeedItem['user']; ··· 26 26 } 27 27 28 28 export default function FeedActivityStatus(props: Props) { 29 - const colorScheme = useColorScheme(); 30 29 const MAX_DISPLAYED = 2; 31 30 const time = getRelativeTime(props.createdAt.toString()); 32 31 const relativeCreatedDate = time === 'just now' ? `Now` : `${time} ago`; ··· 41 40 const remainingCount = collections.length - MAX_DISPLAYED; 42 41 43 42 return ( 44 - <Text fw={500} c={'gray'}> 45 - <Anchor 43 + <Text fw={500}> 44 + <Text 46 45 component={Link} 47 46 href={`/profile/${props.user.handle}`} 48 - c="dark" 49 47 fw={600} 48 + c={'var(--mantine-color-bright)'} 50 49 > 51 50 {sanitizeText(props.user.name)} 52 - </Anchor>{' '} 51 + </Text>{' '} 53 52 {collections.length === 0 ? ( 54 - 'added to library' 53 + <Text span>added to library</Text> 55 54 ) : ( 56 55 <Fragment> 57 - added to{' '} 56 + <Text span>added to </Text> 58 57 {displayedCollections.map( 59 58 (collection: Collection, index: number) => ( 60 59 <span key={collection.id}> ··· 70 69 </span> 71 70 ), 72 71 )} 73 - {remainingCount > 0 && ' and '} 72 + {remainingCount > 0 && <Text span>{' and '}</Text>} 74 73 {remainingCount > 0 && ( 75 74 <Menu shadow="sm"> 76 - <Menu.Target> 75 + <MenuTarget> 77 76 <Text 78 77 fw={600} 79 78 c={'blue'} ··· 83 82 {remainingCount} other collection 84 83 {remainingCount > 1 ? 's' : ''} 85 84 </Text> 86 - </Menu.Target> 87 - <Menu.Dropdown maw={380}> 85 + </MenuTarget> 86 + <MenuDropdown maw={380}> 88 87 <ScrollArea.Autosize mah={150} type="auto"> 89 88 {remainingCollections.map((c) => ( 90 - <Menu.Item 89 + <MenuItem 91 90 key={c.id} 92 91 component={Link} 93 92 href={`/profile/${c.author.handle}/collections/${getRecordKey(c.uri!)}`} ··· 96 95 fw={600} 97 96 > 98 97 {c.name} 99 - </Menu.Item> 98 + </MenuItem> 100 99 ))} 101 100 </ScrollArea.Autosize> 102 - </Menu.Dropdown> 101 + </MenuDropdown> 103 102 </Menu> 104 103 )} 105 104 </Fragment> ··· 112 111 }; 113 112 114 113 return ( 115 - <Card p={0} bg={colorScheme === 'dark' ? 'dark.4' : 'gray.1'} radius={'lg'}> 114 + <Card p={0} className={styles.root} radius={'lg'}> 116 115 <Stack gap={'xs'}> 117 116 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 118 117 <Avatar
+3 -11
src/webapp/features/feeds/components/feedItem/Skeleton.FeedItem.tsx
··· 1 - 'use client'; 2 - 3 1 import UrlCardSkeleton from '@/features/cards/components/urlCard/Skeleton.UrlCard'; 4 - import { Avatar, Card, Group, Paper, Skeleton, Stack } from '@mantine/core'; 5 - import { useColorScheme } from '@mantine/hooks'; 2 + import { Avatar, Card, Group, Skeleton, Stack } from '@mantine/core'; 3 + import styles from '../feedActivityStatus/FeedActivityStatus.module.css'; 6 4 7 5 export default function FeedItemSkeleton() { 8 - const colorScheme = useColorScheme(); 9 - 10 6 return ( 11 7 <Stack gap={'xs'} align="stretch"> 12 8 {/* Feed activity status*/} 13 - <Card 14 - p={0} 15 - bg={colorScheme === 'dark' ? 'dark.4' : 'gray.1'} 16 - radius={'lg'} 17 - > 9 + <Card p={0} className={styles.root} radius={'lg'}> 18 10 <Stack gap={'xs'} align="stretch" w={'100%'}> 19 11 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 20 12 <Avatar />
+1 -1
src/webapp/features/home/containers/homeContainer/HomeContainer.tsx
··· 26 26 import { useNavbarContext } from '@/providers/navbar'; 27 27 28 28 export default function HomeContainer() { 29 - const { data: collectionsData } = useMyCollections({ limit: 8 }); 29 + const { data: collectionsData } = useMyCollections({ limit: 4 }); 30 30 const { data: myCardsData } = useMyCards({ limit: 8 }); 31 31 const { data: profile } = useMyProfile(); 32 32
+1 -1
src/webapp/features/home/containers/homeContainer/Skeleton.HomeContainer.tsx
··· 48 48 </Group> 49 49 50 50 <Grid gutter="md"> 51 - {Array.from({ length: 4 }).map((_, i) => ( 51 + {Array.from({ length: 6 }).map((_, i) => ( 52 52 <GridCol key={i} span={{ base: 12, xs: 6, sm: 4, lg: 3 }}> 53 53 <UrlCardSkeleton /> 54 54 </GridCol>
+8 -4
src/webapp/features/notes/components/noteCard/NoteCard.tsx
··· 30 30 size={'sm'} 31 31 /> 32 32 33 - <Text c={'gray'}> 33 + <Text> 34 34 <Text 35 35 component={Link} 36 36 href={`/profile/${props.author.handle}`} 37 - c={'dark'} 37 + c={'var(--mantine-color-bright)'} 38 38 fw={500} 39 39 span 40 40 > 41 41 {props.author.name} 42 42 </Text> 43 - <Text span>{' ยท '}</Text> 44 - <Text span>{relativeCreateDate} </Text> 43 + <Text c={'gray'} span> 44 + {' ยท '} 45 + </Text> 46 + <Text c={'gray'} span> 47 + {relativeCreateDate}{' '} 48 + </Text> 45 49 </Text> 46 50 </Group> 47 51 </Stack>
+1 -1
src/webapp/features/notes/components/noteCardModal/NoteCardModalContent.tsx
··· 174 174 </Anchor> 175 175 </Tooltip> 176 176 {props.cardContent.title && ( 177 - <Text fw={500} lineClamp={1}> 177 + <Text fw={500} lineClamp={1} c="var(--mantine-color-bright)"> 178 178 {props.cardContent.title} 179 179 </Text> 180 180 )}
+1 -1
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
··· 31 31 /> 32 32 <Stack gap={'sm'} p={'xs'}> 33 33 <Stack gap={'xl'}> 34 - <Grid gutter={'md'} align={'center'} grow> 34 + <Grid gutter={'md'} grow> 35 35 <GridCol span={'auto'}> 36 36 <Avatar 37 37 src={profile.avatarUrl}
+1 -1
src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.tsx
··· 39 39 <Button 40 40 component={Link} 41 41 href={`/profile/${profile.handle}/cards`} 42 - color="dark" 42 + color="var(--mantine-color-dark-filled)" 43 43 leftSection={<FaRegNoteSticky />} 44 44 > 45 45 Cards
+15 -15
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
··· 70 70 <Menu.Target> 71 71 <Button 72 72 variant="subtle" 73 - color={computedColorScheme === 'dark' ? 'gray' : 'dark'} 73 + color={'var(--mantine-color-bright)'} 74 74 fz="md" 75 75 radius="md" 76 76 size="lg" ··· 106 106 107 107 <Menu.Item 108 108 color="gray" 109 - leftSection={<IoMdLogOut size={22} />} 110 - onClick={handleLogout} 111 - > 112 - Log out 113 - </Menu.Item> 114 - 115 - <Menu.Divider /> 116 - 117 - {/*<Menu.Item 118 - color="gray" 119 109 leftSection={ 120 110 colorScheme === 'auto' ? ( 121 - <MdAutoAwesome /> 111 + <MdAutoAwesome size={22} /> 122 112 ) : computedColorScheme === 'dark' ? ( 123 - <MdDarkMode /> 113 + <MdDarkMode size={22} /> 124 114 ) : ( 125 - <MdLightMode /> 115 + <MdLightMode size={22} /> 126 116 ) 127 117 } 128 118 closeMenuOnClick={false} 129 119 onClick={handleThemeToggle} 130 120 > 131 121 Theme: {colorScheme} 132 - </Menu.Item>*/} 122 + </Menu.Item> 123 + 124 + <Menu.Divider /> 125 + 126 + <Menu.Item 127 + color="gray" 128 + leftSection={<IoMdLogOut size={22} />} 129 + onClick={handleLogout} 130 + > 131 + Log out 132 + </Menu.Item> 133 133 134 134 <Menu.Item 135 135 component="a"
+6 -2
src/webapp/features/semble/containers/sembleAside/SembleAside.tsx
··· 41 41 alt={`${lib.user.name}'s avatar`} 42 42 /> 43 43 <Stack gap={0}> 44 - <Text fw={600} lineClamp={1}> 44 + <Text 45 + fw={600} 46 + lineClamp={1} 47 + c={'var(--mantine-color-bright)'} 48 + > 45 49 {lib.user.name} 46 50 </Text> 47 51 <Text fw={600} c={'blue'} lineClamp={1}> 48 - {lib.user.handle} 52 + @{lib.user.handle} 49 53 </Text> 50 54 </Stack> 51 55 </Group>
+1 -1
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
··· 9 9 url: string; 10 10 } 11 11 12 - export default async function SembleContainer(props: Props) { 12 + export default function SembleContainer(props: Props) { 13 13 return ( 14 14 <Container p={0} fluid> 15 15 <SembleHeaderBackground />
+4 -6
src/webapp/features/semble/containers/sembleContainer/SembleHeaderBackground.tsx
··· 1 - 'use client'; 2 - 3 - import { useColorScheme } from '@mantine/hooks'; 4 1 import BG from '@/assets/semble-header-bg.webp'; 5 2 import DarkBG from '@/assets/semble-header-bg-dark.webp'; 6 3 import { Box, Image } from '@mantine/core'; 7 4 8 5 export default function SembleHeaderBackground() { 9 - const colorScheme = useColorScheme(); 10 - 11 6 return ( 12 7 <Box style={{ position: 'relative', width: '100%' }}> 13 8 <Image 14 - src={colorScheme === 'dark' ? DarkBG.src : BG.src} 9 + src={DarkBG.src} 15 10 alt="bg" 16 11 fit="cover" 17 12 w="100%" 18 13 h={80} 14 + lightHidden 19 15 /> 16 + 17 + <Image src={BG.src} alt="bg" fit="cover" w="100%" h={80} darkHidden /> 20 18 21 19 {/* White gradient overlay */} 22 20 <Box
+1 -1
src/webapp/providers/mantine.tsx
··· 12 12 13 13 export default function MantineProvider(props: Props) { 14 14 return ( 15 - <BaseProvider theme={theme} forceColorScheme="light"> 15 + <BaseProvider theme={theme} defaultColorScheme="auto"> 16 16 <Notifications position="bottom-right" /> 17 17 {props.children} 18 18 </BaseProvider>