A social knowledge tool for researchers built on ATProto

Compare changes

Choose any two refs to compare.

Changed files
+1506 -350
.vscode
src
modules
webapp
app
(auth)
login
(dashboard)
bookmarklet
assets
components
navigation
bottomBarItem
guestNavbar
features
auth
components
loginForm
signUpForm
cards
components
addCardDrawer
cardToBeAddedPreview
urlCard
lib
collections
feeds
home
notes
components
editNoteDrawer
noteCard
noteCardModal
profile
components
profileHeader
profileHoverCard
profileMenu
profileTabs
semble
providers
styles
-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.
+1 -1
README.md
··· 2 2 3 3 ![semble banner image](src/webapp/app/opengraph-image.jpg) 4 4 5 - [Semble](https://semble.so/) is a social knowledge network for online research built on the [AT Protocol](https://atproto.com/). 5 + [Semble](https://semble.so/) is a social knowledge network for research built on the [AT Protocol](https://atproto.com/). 6 6 7 7 In Semble, you can save links as _Cards_ to _Collections_ and discover what other users are doing in the _Explore_ tab. 8 8
+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,
+2 -2
src/modules/cards/domain/value-objects/URL.ts
··· 31 31 try { 32 32 // Validate URL format using the global URL constructor 33 33 const parsedUrl = new globalThis.URL(trimmedUrl); 34 - 34 + 35 35 // Add trailing slash only to truly bare root URLs 36 36 // (no path, no query parameters, no fragments) 37 37 let normalizedUrl = trimmedUrl; ··· 43 43 ) { 44 44 normalizedUrl = trimmedUrl + '/'; 45 45 } 46 - 46 + 47 47 return ok(new URL({ value: normalizedUrl })); 48 48 } catch (error) { 49 49 return err(new InvalidURLError('Invalid URL format'));
+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
+2 -2
src/webapp/app/(auth)/login/page.tsx
··· 71 71 <Popover withArrow shadow="sm"> 72 72 <PopoverTarget> 73 73 <Button 74 - variant="white" 74 + variant="transparent" 75 75 size="md" 76 76 fw={500} 77 77 fs={'italic'} 78 - c={'stone'} 78 + c={'gray'} 79 79 rightSection={<IoMdHelpCircleOutline size={22} />} 80 80 > 81 81 How your Cosmik Network account works
+75 -47
src/webapp/app/(dashboard)/error.tsx
··· 13 13 } from '@mantine/core'; 14 14 import SembleLogo from '@/assets/semble-logo.svg'; 15 15 import BG from '@/assets/semble-bg.webp'; 16 + import DarkBG from '@/assets/semble-bg-dark.png'; 16 17 import Link from 'next/link'; 17 18 import { BiRightArrowAlt } from 'react-icons/bi'; 18 19 19 20 export default function Error() { 20 21 return ( 21 - <BackgroundImage 22 - src={BG.src} 23 - h={'100svh'} 24 - pos={'fixed'} 25 - top={0} 26 - left={0} 27 - style={{ zIndex: 102 }} 28 - > 29 - <Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}> 30 - <Container size={'xl'} p={'md'} my={'auto'}> 31 - <Stack> 32 - <Stack align="center" gap={'xs'}> 33 - <Image 34 - src={SembleLogo.src} 35 - alt="Semble logo" 36 - w={48} 37 - h={64.5} 38 - mx={'auto'} 39 - /> 40 - <Badge size="sm">Alpha</Badge> 41 - </Stack> 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> 42 35 43 - <Stack> 44 - <Text fz={'h1'} fw={600} ta={'center'}> 45 - A social knowledge network for researchers 46 - </Text> 47 - <Text fz={'h3'} fw={600} c={'#1F6144'} ta={'center'}> 48 - Follow your peersโ€™ research trails. Surface and discover new 49 - connections. Built on ATProto so you own your data. 50 - </Text> 51 - </Stack> 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 + } 52 51 53 - <Group justify="center" gap="md" mt={'lg'}> 54 - <Button component={Link} href="/signup" size="lg"> 55 - Sign up 56 - </Button> 52 + function Content() { 53 + return ( 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> 57 67 58 - <Button 59 - component={Link} 60 - href="/login" 61 - size="lg" 62 - color="dark" 63 - rightSection={<BiRightArrowAlt size={22} />} 64 - > 65 - Log in 66 - </Button> 67 - </Group> 68 + <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> 68 80 </Stack> 69 - </Container> 70 - </Center> 71 - </BackgroundImage> 81 + 82 + <Group justify="center" gap="md" mt={'lg'}> 83 + <Button component={Link} href="/signup" size="lg"> 84 + Sign up 85 + </Button> 86 + 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> 72 100 ); 73 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 defaultColorScheme="light" /> 29 + <ColorSchemeScript defaultColorScheme="auto" /> 30 30 </head> 31 31 <body className={GlobalStyles.main}> 32 32 <Providers>{children}</Providers>
+191 -141
src/webapp/app/page.tsx
··· 1 + 'use client'; 2 + 1 3 import { 2 4 ActionIcon, 3 5 SimpleGrid, ··· 18 20 import { BiRightArrowAlt } from 'react-icons/bi'; 19 21 import { RiArrowRightUpLine } from 'react-icons/ri'; 20 22 import BG from '@/assets/semble-bg.webp'; 23 + import DarkBG from '@/assets/semble-bg-dark.png'; 21 24 import CosmikLogo from '@/assets/cosmik-logo-full.svg'; 25 + import CosmikLogoWhite from '@/assets/cosmik-logo-full-white.svg'; 22 26 import CurateIcon from '@/assets/icons/curate-icon.svg'; 23 27 import CommunityIcon from '@/assets/icons/community-icon.svg'; 24 28 import DBIcon from '@/assets/icons/db-icon.svg'; ··· 29 33 30 34 export default function Home() { 31 35 return ( 32 - <BackgroundImage src={BG.src} h={'100svh'}> 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 + } 49 + 50 + function Content() { 51 + return ( 52 + <> 33 53 <script async src="https://tally.so/widgets/embed.js" /> 34 - <Container size={'xl'} p={'md'} my={'auto'}> 54 + <Container size="xl" p="md" my="auto"> 35 55 <Group justify="space-between"> 36 56 <Stack gap={6} align="center"> 37 - <Image src={SembleLogo.src} alt="Semble logo" w={30} h={'auto'} /> 57 + <Image src={SembleLogo.src} alt="Semble logo" w={30} h="auto" /> 38 58 <Badge size="sm">Alpha</Badge> 39 59 </Stack> 40 60 <Button ··· 49 69 </Button> 50 70 </Group> 51 71 </Container> 52 - <Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}> 53 - <Container size={'xl'} p={'md'} my={'auto'}> 54 - <Stack align="center" gap={'5rem'}> 55 - <Stack gap={'xs'} align="center" maw={550} mx={'auto'}> 56 - <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"> 57 78 A social knowledge network for researchers 58 79 </Title> 59 - <Title order={2} fw={600} fz={'xl'} c={'#1F6144'} ta={'center'}> 80 + 81 + {/* light mode subtitle */} 82 + <Title 83 + order={2} 84 + fw={600} 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 102 + > 60 103 Follow your peersโ€™ research trails. Surface and discover new 61 104 connections. Built on ATProto so you own your data. 62 105 </Title> 63 106 64 107 {process.env.VERCEL_ENV !== 'production' && ( 65 - <Group gap="md" mt={'lg'}> 108 + <Group gap="md" mt="lg"> 66 109 <Button component={Link} href="/signup" size="lg"> 67 110 Sign up 68 111 </Button> ··· 71 114 component={Link} 72 115 href="/login" 73 116 size="lg" 74 - color="dark" 117 + color="var(--mantine-color-dark-filled)" 75 118 rightSection={<BiRightArrowAlt size={22} />} 76 119 > 77 120 Log in ··· 85 128 spacing={{ base: 'xl' }} 86 129 mt={{ base: '1rem', xs: '5rem' }} 87 130 > 88 - <Stack gap={'xs'}> 131 + <Stack gap="xs"> 89 132 <Image src={CurateIcon.src} alt="Curate icon" w={28} /> 90 133 <Text> 91 - <Text fw={600} fz={'lg'} span> 134 + <Text fw={600} fz="lg" span> 92 135 Curate your research trails. 93 136 </Text>{' '} 94 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 137 + <Text fw={500} fz="lg" c="dark.2" span> 95 138 Collect interesting links, add notes, and organize them into 96 139 shareable collections. Build trails others can explore and 97 140 extend. 98 141 </Text> 99 142 </Text> 100 143 </Stack> 101 - <Stack gap={'xs'}> 144 + <Stack gap="xs"> 102 145 <Image src={CommunityIcon.src} alt="Community icon" w={28} /> 103 146 <Text> 104 - <Text fw={600} fz={'lg'} span> 147 + <Text fw={600} fz="lg" span> 105 148 Connect with peers. 106 149 </Text>{' '} 107 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 150 + <Text fw={500} fz="lg" c="dark.2" span> 108 151 See what your peers are sharing and find new collaborators 109 152 with shared interests. Experience research rabbit holes, 110 153 together. 111 154 </Text> 112 155 </Text> 113 156 </Stack> 114 - <Stack gap={'xs'}> 157 + <Stack gap="xs"> 115 158 <Image src={DBIcon.src} alt="Database icon" w={28} /> 116 159 <Text> 117 - <Text fw={600} fz={'lg'} span> 160 + <Text fw={600} fz="lg" span> 118 161 Own your data. 119 162 </Text>{' '} 120 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 163 + <Text fw={500} fz="lg" c="dark.2" span> 121 164 Built on ATProto, new apps will come to you. No more 122 165 rebuilding your social graph and data when apps pivot and 123 166 shut down. 124 167 </Text> 125 168 </Text> 126 169 </Stack> 127 - <Stack gap={'xs'}> 170 + <Stack gap="xs"> 128 171 <Image src={BigPictureIcon.src} alt="Big picture icon" w={28} /> 129 172 <Text> 130 - <Text fw={600} fz={'lg'} span> 173 + <Text fw={600} fz="lg" span> 131 174 See the bigger picture. 132 175 </Text>{' '} 133 - <Text fw={500} fz={'lg'} c={'dark.2'} span> 176 + <Text fw={500} fz="lg" c="dark.2" span> 134 177 Find relevant research based on your network. Get the extra 135 178 context that matters before you dive into a long read. 136 179 </Text> ··· 138 181 </Stack> 139 182 </SimpleGrid> 140 183 141 - <Box 142 - component="footer" 143 - px={'md'} 144 - py={'xs'} 145 - mt={'xl'} 146 - pos={'relative'} 147 - > 148 - <Stack align="center" gap={'xs'}> 149 - <Group gap="0"> 150 - <ActionIcon 151 - component="a" 152 - href="https://bsky.app/profile/cosmik.network" 153 - target="_blank" 154 - variant="subtle" 155 - color={'dark.2'} 156 - radius={'xl'} 157 - size={'xl'} 158 - m={0} 159 - > 160 - <FaBluesky size={22} /> 161 - </ActionIcon> 162 - <ActionIcon 163 - component="a" 164 - href="https://tangled.org/@cosmik.network/semble" 165 - target="_blank" 166 - variant="subtle" 167 - color={'dark.2'} 168 - radius={'xl'} 169 - size={'xl'} 170 - > 171 - <Image 172 - src={TangledIcon.src} 173 - alt="Tangled logo" 174 - w={'auto'} 175 - h={22} 176 - /> 177 - </ActionIcon> 178 - <ActionIcon 179 - component="a" 180 - href="https://github.com/cosmik-network" 181 - target="_blank" 182 - variant="subtle" 183 - color={'dark.2'} 184 - radius={'xl'} 185 - size={'xl'} 186 - > 187 - <FaGithub size={22} /> 188 - </ActionIcon> 189 - <ActionIcon 190 - component="a" 191 - href="https://discord.gg/SHvvysb73e" 192 - target="_blank" 193 - variant="subtle" 194 - color={'dark.2'} 195 - radius={'xl'} 196 - size={'xl'} 197 - > 198 - <FaDiscord size={22} /> 199 - </ActionIcon> 200 - </Group> 201 - <Button 202 - component="a" 203 - href="https://blog.cosmik.network" 204 - target="_blank" 205 - variant="light" 206 - color="dark.1" 207 - fw={600} 208 - rightSection={<RiArrowRightUpLine />} 209 - > 210 - Follow our blog for updates 211 - </Button> 212 - <Stack align="center" gap={'0'}> 213 - <Text c="dark.1" fw={600} ta="center"> 214 - Made by &nbsp; 215 - <Anchor 216 - href="https://cosmik.network/" 217 - target="_blank" 218 - style={{ verticalAlign: 'middle' }} 219 - > 220 - <Box 221 - component="span" 222 - display="inline-flex" 223 - style={{ verticalAlign: 'middle' }} 224 - > 225 - <Image 226 - src={CosmikLogo.src} 227 - alt="Cosmik logo" 228 - w={92} 229 - h={28.4} 230 - /> 231 - </Box> 232 - </Anchor> 233 - &nbsp;&nbsp; 234 - <Text c="dark.1" fw={600} span> 235 - with support from&nbsp; 236 - <Anchor 237 - href="https://www.openphilanthropy.org/" 238 - target="_blank" 239 - c="dark.2" 240 - fw={600} 241 - > 242 - Open Philanthropy 243 - </Anchor>{' '} 244 - and{' '} 245 - <Anchor 246 - href="https://astera.org/" 247 - target="_blank" 248 - c="dark.2" 249 - fw={600} 250 - > 251 - Astera 252 - </Anchor> 253 - </Text> 254 - </Text> 255 - </Stack> 256 - </Stack> 257 - </Box> 184 + <Footer /> 258 185 </Stack> 259 186 </Container> 260 187 </Center> 261 - </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> 262 312 ); 263 313 }
+10
src/webapp/assets/cosmik-logo-full-white.svg
··· 1 + <svg width="144" height="46" viewBox="0 0 144 46" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <mask id="mask0_327_268" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="9" width="28" height="28"> 3 + <path d="M28 9.5H0V36.5H28V9.5Z" fill="white"/> 4 + </mask> 5 + <g mask="url(#mask0_327_268)"> 6 + <path d="M0 23.9995C0 30.9031 5.59644 36.4995 12.5 36.4995C18.7765 36.4995 23.9726 31.8735 24.8647 25.845C24.9411 25.3288 24.332 25.0129 23.8852 25.2825C22.6049 26.055 21.1043 26.4995 19.5 26.4995C14.8056 26.4995 11 22.6939 11 17.9995C11 15.9273 11.7415 14.0283 12.9737 12.5534C13.3084 12.1526 13.0889 11.5024 12.5667 11.4997L12.5 11.4995C5.59644 11.4995 0 17.096 0 23.9995Z" fill="white"/> 7 + <path d="M28.0716 18C28.0716 13.3056 24.266 9.5 19.5716 9.5C17.9297 9.5 16.3966 9.96552 15.0969 10.7718C14.6532 11.047 14.8357 11.694 15.3446 11.8109C20.6468 13.0293 24.6392 17.7069 25.0383 23.1268C25.0767 23.6472 25.6879 23.9255 26.0275 23.5293C27.3018 22.0429 28.0716 20.1114 28.0716 18Z" fill="white"/> 8 + </g> 9 + <path d="M45.3063 32.3201C43.6885 32.3201 42.2752 31.9823 41.0663 31.3068C39.8574 30.6134 38.9152 29.6445 38.2396 28.4001C37.5818 27.1557 37.2529 25.689 37.2529 24.0001C37.2529 22.3112 37.5818 20.8445 38.2396 19.6001C38.8974 18.3557 39.8307 17.3957 41.0396 16.7201C42.2485 16.0268 43.6707 15.6801 45.3063 15.6801C47.2618 15.6801 48.8885 16.169 50.1863 17.1468C51.5018 18.1068 52.364 19.4934 52.7729 21.3068H49.7596C49.4574 20.2757 48.9329 19.5201 48.1863 19.0401C47.4574 18.5423 46.4974 18.2934 45.3063 18.2934C43.724 18.2934 42.4796 18.7912 41.5729 19.7868C40.684 20.7823 40.2396 22.1868 40.2396 24.0001C40.2396 25.7957 40.684 27.2001 41.5729 28.2134C42.4796 29.209 43.724 29.7068 45.3063 29.7068C47.7774 29.7068 49.2974 28.6134 49.8663 26.4268H52.8796C52.4885 28.329 51.6352 29.7868 50.3196 30.8001C49.004 31.8134 47.3329 32.3201 45.3063 32.3201ZM62.81 32.3201C61.1567 32.3201 59.7078 31.9823 58.4633 31.3068C57.2367 30.6134 56.2856 29.6445 55.61 28.4001C54.9345 27.1557 54.5967 25.689 54.5967 24.0001C54.5967 22.3112 54.9345 20.8445 55.61 19.6001C56.2856 18.3557 57.2367 17.3957 58.4633 16.7201C59.7078 16.0268 61.1567 15.6801 62.81 15.6801C64.5167 15.6801 65.9745 16.0179 67.1834 16.6934C68.41 17.369 69.3522 18.329 70.01 19.5734C70.6856 20.8179 71.0234 22.2934 71.0234 24.0001C71.0234 25.689 70.6856 27.1645 70.01 28.4268C69.3522 29.6712 68.41 30.6312 67.1834 31.3068C65.9567 31.9823 64.4989 32.3201 62.81 32.3201ZM62.81 29.7068C64.4634 29.7068 65.7434 29.209 66.65 28.2134C67.5745 27.2001 68.0367 25.7957 68.0367 24.0001C68.0367 22.1868 67.5745 20.7823 66.65 19.7868C65.7434 18.7912 64.4634 18.2934 62.81 18.2934C61.1745 18.2934 59.8945 18.8001 58.97 19.8134C58.0456 20.809 57.5834 22.2045 57.5834 24.0001C57.5834 25.7957 58.0456 27.2001 58.97 28.2134C59.8945 29.209 61.1745 29.7068 62.81 29.7068ZM80.6542 32.3201C78.432 32.3201 76.6809 31.8579 75.4009 30.9334C74.1209 30.009 73.4186 28.7201 73.2942 27.0668H76.2542C76.3786 28.0445 76.8053 28.7645 77.5342 29.2268C78.2631 29.689 79.3386 29.9201 80.7609 29.9201C83.2497 29.9201 84.4942 29.1379 84.4942 27.5734C84.4942 26.9334 84.2986 26.4445 83.9075 26.1068C83.5342 25.7512 82.8942 25.4934 81.9875 25.3334L78.4942 24.6934C75.5253 24.1423 74.0408 22.729 74.0408 20.4534C74.0408 18.9779 74.6009 17.8134 75.7209 16.9601C76.8408 16.1068 78.3697 15.6801 80.3075 15.6801C82.3164 15.6801 83.9075 16.1157 85.0809 16.9868C86.272 17.8579 86.9475 19.0845 87.1075 20.6668H84.2009C84.0231 19.7779 83.6053 19.1201 82.9475 18.6934C82.3075 18.2668 81.4009 18.0534 80.2275 18.0534C79.1431 18.0534 78.2986 18.2401 77.6942 18.6134C77.1075 18.9868 76.8142 19.529 76.8142 20.2401C76.8142 20.7912 77.0008 21.2268 77.3742 21.5468C77.7653 21.849 78.3875 22.0801 79.2409 22.2401L82.7342 22.9068C84.2986 23.1912 85.4453 23.6979 86.1742 24.4268C86.9031 25.1557 87.2675 26.1334 87.2675 27.3601C87.2675 28.9245 86.6986 30.1423 85.5609 31.0134C84.4231 31.8845 82.7875 32.3201 80.6542 32.3201ZM91.0717 32.0001V19.0934H89.525V16.0001H92.725V18.4534H94.005C94.4672 17.5645 95.1339 16.889 96.005 16.4268C96.8939 15.9468 97.9606 15.7068 99.205 15.7068C100.468 15.7068 101.534 15.9557 102.405 16.4534C103.294 16.9512 104.005 17.7157 104.539 18.7468H104.619C105.739 16.7201 107.579 15.7068 110.139 15.7068C112.112 15.7068 113.65 16.2757 114.752 17.4134C115.854 18.5512 116.405 20.1157 116.405 22.1068V32.0001H113.419V22.5334C113.419 19.7245 112.05 18.3201 109.312 18.3201C106.592 18.3201 105.232 19.7245 105.232 22.5334V32.0001H102.245V22.5334C102.245 19.7245 100.877 18.3201 98.1384 18.3201C96.805 18.3201 95.7917 18.6845 95.0984 19.4134C94.405 20.1245 94.0584 21.1645 94.0584 22.5334V32.0001H91.0717ZM120.789 32.0001V16.0001H123.775V32.0001H120.789ZM122.282 13.8134C121.784 13.8134 121.358 13.6357 121.002 13.2801C120.647 12.9245 120.469 12.4979 120.469 12.0001C120.469 11.5023 120.647 11.0757 121.002 10.7201C121.358 10.3645 121.784 10.1868 122.282 10.1868C122.78 10.1868 123.207 10.3645 123.562 10.7201C123.918 11.0757 124.095 11.5023 124.095 12.0001C124.095 12.4979 123.918 12.9245 123.562 13.2801C123.207 13.6357 122.78 13.8134 122.282 13.8134ZM128.254 32.0001V9.6001H131.24V23.2001H131.347L139.054 16.0001H142.707L135.374 22.6134L143.32 32.0001H139.694L133.347 24.4534L131.24 26.1601V32.0001H128.254Z" fill="white"/> 10 + </svg>
src/webapp/assets/semble-bg-dark.png

This is a binary file and will not be displayed.

src/webapp/assets/semble-bg-dark.webp

This is a binary file and will not be displayed.

src/webapp/assets/semble-header-bg-dark.webp

This is a binary file and will not be displayed.

+1 -8
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 ··· 30 28 href={props.href} 31 29 variant={isActive ? 'light' : 'transparent'} 32 30 size={'lg'} 33 - bg={ 34 - isActive 35 - ? `${colorScheme === 'dark' ? 'dark.5' : 'gray.1'}` 36 - : 'transparent' 37 - } 38 31 color="gray" 39 32 > 40 33 {renderIcon()}
+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
+2 -2
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
··· 89 89 New Card 90 90 </Drawer.Title> 91 91 </Drawer.Header> 92 - <Container size={'sm'}> 92 + <Container size={'sm'} p={0}> 93 93 <form onSubmit={handleAddCard}> 94 94 <Stack gap={'xl'}> 95 95 <Stack> ··· 152 152 Add to collections 153 153 </Drawer.Title> 154 154 </Drawer.Header> 155 - <Container size={'xs'}> 155 + <Container size={'xs'} p={0}> 156 156 <Suspense fallback={<CollectionSelectorSkeleton />}> 157 157 <CollectionSelector 158 158 isOpen={collectionSelectorOpened}
+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 -3
src/webapp/features/collections/components/collectionNavItem/CollectionNavItem.tsx
··· 2 2 import { Badge, NavLink } from '@mantine/core'; 3 3 import Link from 'next/link'; 4 4 import { usePathname } from 'next/navigation'; 5 + import styles from './CollectionNavItem.module.css'; 5 6 6 7 interface Props { 7 8 name: string; ··· 20 21 href={props.url} 21 22 label={props.name} 22 23 variant="subtle" 23 - c={isActive ? 'dark' : 'gray'} 24 + className={ 25 + isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink 26 + } 24 27 onClick={toggleMobile} 25 28 rightSection={ 26 29 props.cardCount > 0 ? ( 27 30 <Badge 28 - variant={isActive ? 'filled' : 'light'} 29 - color={isActive ? 'dark' : 'gray'} 31 + className={isActive ? styles.badgeActive : styles.badge} 30 32 circle 31 33 > 32 34 {props.cardCount}
+1 -1
src/webapp/features/collections/components/createCollectionDrawer/CreateCollectionDrawer.tsx
··· 78 78 </Drawer.Title> 79 79 </Drawer.Header> 80 80 81 - <Container size={'sm'}> 81 + <Container size={'sm'} p={0}> 82 82 <form onSubmit={handleCreateCollection}> 83 83 <Stack> 84 84 <TextInput
+1 -1
src/webapp/features/collections/components/editCollectionDrawer/EditCollectionDrawer.tsx
··· 72 72 </Drawer.Title> 73 73 </Drawer.Header> 74 74 75 - <Container size="sm"> 75 + <Container size="sm" p={0}> 76 76 <form onSubmit={handleUpdateCollection}> 77 77 <Stack> 78 78 <TextInput
+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 + }
+57 -14
src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx
··· 1 - 'use client'; 2 - 3 - import { Anchor, Avatar, Card, Group, Paper, Stack, Text } from '@mantine/core'; 1 + import { 2 + Anchor, 3 + Avatar, 4 + Card, 5 + Group, 6 + Menu, 7 + MenuDropdown, 8 + MenuItem, 9 + MenuTarget, 10 + ScrollArea, 11 + Stack, 12 + Text, 13 + } from '@mantine/core'; 4 14 import { FeedItem, Collection } from '@/api-client'; 5 15 import { Fragment } from 'react'; 6 16 import Link from 'next/link'; 17 + import styles from './FeedActivityStatus.module.css'; 7 18 import { getRelativeTime } from '@/lib/utils/time'; 8 19 import { getRecordKey } from '@/lib/utils/atproto'; 9 20 import { sanitizeText } from '@/lib/utils/text'; 10 - import { useColorScheme } from '@mantine/hooks'; 11 21 12 22 interface Props { 13 23 user: FeedItem['user']; ··· 16 26 } 17 27 18 28 export default function FeedActivityStatus(props: Props) { 19 - const colorScheme = useColorScheme(); 20 29 const MAX_DISPLAYED = 2; 21 30 const time = getRelativeTime(props.createdAt.toString()); 22 31 const relativeCreatedDate = time === 'just now' ? `Now` : `${time} ago`; ··· 24 33 const renderActivityText = () => { 25 34 const collections = props.collections ?? []; 26 35 const displayedCollections = collections.slice(0, MAX_DISPLAYED); 36 + const remainingCollections = collections.slice( 37 + MAX_DISPLAYED, 38 + collections.length, 39 + ); 27 40 const remainingCount = collections.length - MAX_DISPLAYED; 28 41 29 42 return ( 30 - <Text fw={500} c={colorScheme === 'dark' ? 'gray' : 'gray.7'}> 31 - <Anchor 43 + <Text fw={500}> 44 + <Text 32 45 component={Link} 33 46 href={`/profile/${props.user.handle}`} 34 - c="blue" 35 47 fw={600} 48 + c={'var(--mantine-color-bright)'} 36 49 > 37 50 {sanitizeText(props.user.name)} 38 - </Anchor>{' '} 51 + </Text>{' '} 39 52 {collections.length === 0 ? ( 40 - 'added to library' 53 + <Text span>added to library</Text> 41 54 ) : ( 42 55 <Fragment> 43 - added to{' '} 56 + <Text span>added to </Text> 44 57 {displayedCollections.map( 45 58 (collection: Collection, index: number) => ( 46 59 <span key={collection.id}> ··· 56 69 </span> 57 70 ), 58 71 )} 59 - {remainingCount > 0 && 60 - ` and ${remainingCount} other collection${remainingCount > 1 ? 's' : ''}`} 72 + {remainingCount > 0 && <Text span>{' and '}</Text>} 73 + {remainingCount > 0 && ( 74 + <Menu shadow="sm"> 75 + <MenuTarget> 76 + <Text 77 + fw={600} 78 + c={'blue'} 79 + style={{ cursor: 'pointer', userSelect: 'none' }} 80 + span 81 + > 82 + {remainingCount} other collection 83 + {remainingCount > 1 ? 's' : ''} 84 + </Text> 85 + </MenuTarget> 86 + <MenuDropdown maw={380}> 87 + <ScrollArea.Autosize mah={150} type="auto"> 88 + {remainingCollections.map((c) => ( 89 + <MenuItem 90 + key={c.id} 91 + component={Link} 92 + href={`/profile/${c.author.handle}/collections/${getRecordKey(c.uri!)}`} 93 + target="_blank" 94 + c="blue" 95 + fw={600} 96 + > 97 + {c.name} 98 + </MenuItem> 99 + ))} 100 + </ScrollArea.Autosize> 101 + </MenuDropdown> 102 + </Menu> 103 + )} 61 104 </Fragment> 62 105 )} 63 106 <Text fz={'sm'} fw={600} c={'gray'} span display={'block'}> ··· 68 111 }; 69 112 70 113 return ( 71 - <Card p={0} bg={colorScheme === 'dark' ? 'dark.4' : 'gray.1'} radius={'lg'}> 114 + <Card p={0} className={styles.root} radius={'lg'}> 72 115 <Stack gap={'xs'}> 73 116 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 74 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>
+1 -1
src/webapp/features/notes/components/editNoteDrawer/EditNoteDrawer.tsx
··· 68 68 </Drawer.Title> 69 69 </Drawer.Header> 70 70 71 - <Container size="sm"> 71 + <Container size="sm" p={0}> 72 72 <form onSubmit={handleUpdateNote}> 73 73 <Stack> 74 74 <Textarea
+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
+60 -11
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
··· 6 6 Menu, 7 7 Image, 8 8 Button, 9 + useMantineColorScheme, 10 + useComputedColorScheme, 9 11 } from '@mantine/core'; 10 12 import useMyProfile from '../../lib/queries/useMyProfile'; 11 13 import CosmikLogo from '@/assets/cosmik-logo-full.svg'; 12 - import { MdBugReport } from 'react-icons/md'; 14 + import CosmikLogoWhite from '@/assets/cosmik-logo-full-white.svg'; 15 + import { 16 + MdBugReport, 17 + MdDarkMode, 18 + MdLightMode, 19 + MdAutoAwesome, 20 + } from 'react-icons/md'; 13 21 import { useAuth } from '@/hooks/useAuth'; 14 22 import { useRouter } from 'next/navigation'; 15 23 import Link from 'next/link'; 16 24 import { IoMdLogOut } from 'react-icons/io'; 17 25 import { useNavbarContext } from '@/providers/navbar'; 18 26 import { BiSolidUserCircle } from 'react-icons/bi'; 19 - import { useColorScheme } from '@mantine/hooks'; 20 27 21 28 export default function ProfileMenu() { 22 29 const router = useRouter(); 23 - const colorScheme = useColorScheme(); 24 30 const { toggleMobile } = useNavbarContext(); 25 31 const { data, error, isPending } = useMyProfile(); 26 32 const { logout } = useAuth(); 27 33 34 + const { colorScheme, setColorScheme } = useMantineColorScheme(); 35 + const computedColorScheme = useComputedColorScheme('light', { 36 + getInitialValueInEffect: true, 37 + }); 38 + 28 39 const handleLogout = async () => { 29 40 try { 30 41 await logout(); ··· 34 45 } 35 46 }; 36 47 48 + const handleThemeToggle = () => { 49 + const nextScheme = 50 + colorScheme === 'light' 51 + ? 'dark' 52 + : colorScheme === 'dark' 53 + ? 'auto' 54 + : 'light'; 55 + 56 + setColorScheme(nextScheme); 57 + }; 58 + 37 59 if (isPending || !data) { 38 - return <Skeleton w={38} h={38} radius={'md'} ml={4} />; 60 + return <Skeleton w={38} h={38} radius="md" ml={4} />; 39 61 } 40 62 41 63 if (error) { ··· 48 70 <Menu.Target> 49 71 <Button 50 72 variant="subtle" 51 - color={colorScheme === 'dark' ? 'gray' : 'dark'} 52 - fz={'md'} 53 - radius={'md'} 73 + color={'var(--mantine-color-bright)'} 74 + fz="md" 75 + radius="md" 54 76 size="lg" 55 77 px={3} 56 - fullWidth={true} 78 + fullWidth 57 79 justify="start" 58 80 leftSection={<Avatar src={data.avatarUrl} />} 59 81 > 60 82 {data.name} 61 83 </Button> 62 84 </Menu.Target> 85 + 63 86 <Menu.Dropdown> 64 87 <Menu.Item 65 88 component={Link} ··· 83 106 84 107 <Menu.Item 85 108 color="gray" 109 + leftSection={ 110 + colorScheme === 'auto' ? ( 111 + <MdAutoAwesome size={22} /> 112 + ) : computedColorScheme === 'dark' ? ( 113 + <MdDarkMode size={22} /> 114 + ) : ( 115 + <MdLightMode size={22} /> 116 + ) 117 + } 118 + closeMenuOnClick={false} 119 + onClick={handleThemeToggle} 120 + > 121 + Theme: {colorScheme} 122 + </Menu.Item> 123 + 124 + <Menu.Divider /> 125 + 126 + <Menu.Item 127 + color="gray" 86 128 leftSection={<IoMdLogOut size={22} />} 87 129 onClick={handleLogout} 88 130 > 89 131 Log out 90 132 </Menu.Item> 91 133 92 - <Menu.Divider /> 93 - 94 134 <Menu.Item 95 135 component="a" 96 136 href="https://cosmik.network/" 97 137 target="_blank" 98 138 > 99 - <Image src={CosmikLogo.src} alt="Cosmik logo" w={'auto'} h={24} /> 139 + <Image 140 + src={ 141 + computedColorScheme === 'dark' 142 + ? CosmikLogoWhite.src 143 + : CosmikLogo.src 144 + } 145 + alt="Cosmik logo" 146 + w="auto" 147 + h={24} 148 + /> 100 149 </Menu.Item> 101 150 </Menu.Dropdown> 102 151 </Menu>
+14 -7
src/webapp/features/profile/components/profileTabs/TabItem.tsx
··· 1 - import { Anchor, Tabs } from '@mantine/core'; 1 + 'use client'; 2 + 3 + import { Tabs } from '@mantine/core'; 2 4 import classes from './TabItem.module.css'; 3 - import Link from 'next/link'; 5 + import { useRouter } from 'next/navigation'; 4 6 5 7 interface Props { 6 8 value: string; ··· 9 11 } 10 12 11 13 export default function TabItem(props: Props) { 14 + const router = useRouter(); 15 + 12 16 return ( 13 - <Anchor component={Link} href={props.href} c={'dark'} underline="never"> 14 - <Tabs.Tab value={props.value} className={classes.tab} fw={600}> 15 - {props.children} 16 - </Tabs.Tab> 17 - </Anchor> 17 + <Tabs.Tab 18 + value={props.value} 19 + className={classes.tab} 20 + fw={600} 21 + onClick={() => router.push(props.href)} 22 + > 23 + {props.children} 24 + </Tabs.Tab> 18 25 ); 19 26 }
+2 -3
src/webapp/features/semble/components/SembleHeader/SembleHeader.tsx
··· 17 17 import UrlAddedBySummary from '../urlAddedBySummary/UrlAddedBySummary'; 18 18 import SembleActions from '../sembleActions/SembleActions'; 19 19 import { verifySessionOnServer } from '@/lib/auth/dal.server'; 20 + import GuestSembleActions from '../sembleActions/GusetSembleActions'; 20 21 21 22 interface Props { 22 23 url: string; ··· 80 81 {session ? ( 81 82 <SembleActions url={props.url} /> 82 83 ) : ( 83 - <Button size="md" component={Link} href={'/login'}> 84 - Log in to add 85 - </Button> 84 + <GuestSembleActions url={props.url} /> 86 85 )} 87 86 </Stack> 88 87 </GridCol>
+6 -2
src/webapp/features/semble/components/SembleHeader/Skeleton.SembleHeader.tsx
··· 1 - import { Stack, Grid, GridCol, Text, Skeleton } from '@mantine/core'; 1 + import { Stack, Grid, GridCol, Text, Skeleton, Group } from '@mantine/core'; 2 2 import UrlAddedBySummarySkeleton from '../urlAddedBySummary/Skeleton.UrlAddedBySummary'; 3 3 4 4 export default function SembleHeaderSkeleton() { ··· 25 25 </Stack> 26 26 </GridCol> 27 27 <GridCol span={{ base: 12, sm: 'content' }}> 28 - <Stack gap={'sm'} align="start" flex={1}> 28 + <Stack gap={'sm'} align="center" flex={1}> 29 29 <Skeleton h={150} w={300} maw={'100%'} /> 30 30 31 31 {/*<SembleActions />*/} 32 + <Group gap={'xs'}> 33 + <Skeleton w={44} h={44} circle /> 34 + <Skeleton w={131} h={44} radius={'xl'} /> 35 + </Group> 32 36 </Stack> 33 37 </GridCol> 34 38 </Grid>
+53
src/webapp/features/semble/components/sembleActions/GusetSembleActions.tsx
··· 1 + 'use client'; 2 + 3 + import { ActionIcon, Button, CopyButton, Group, Tooltip } from '@mantine/core'; 4 + import Link from 'next/link'; 5 + import { MdIosShare } from 'react-icons/md'; 6 + import { notifications } from '@mantine/notifications'; 7 + 8 + interface Props { 9 + url: string; 10 + } 11 + 12 + export default function GuestSembleActions(props: Props) { 13 + const shareLink = 14 + typeof window !== 'undefined' 15 + ? `${window.location.origin}/url?id=${props.url}` 16 + : ''; 17 + 18 + return ( 19 + <Group gap={'xs'}> 20 + <CopyButton value={shareLink}> 21 + {({ copied, copy }) => ( 22 + <Tooltip 23 + label={copied ? 'Link copied!' : 'Share'} 24 + withArrow 25 + position="top" 26 + > 27 + <ActionIcon 28 + variant="light" 29 + color="gray" 30 + size={'xl'} 31 + radius={'xl'} 32 + onClick={() => { 33 + copy(); 34 + 35 + if (copied) return; 36 + notifications.show({ 37 + message: 'Link copied!', 38 + position: 'top-center', 39 + id: copied.toString(), 40 + }); 41 + }} 42 + > 43 + <MdIosShare size={22} /> 44 + </ActionIcon> 45 + </Tooltip> 46 + )} 47 + </CopyButton> 48 + <Button size="md" component={Link} href={'/login'}> 49 + Log in to add 50 + </Button> 51 + </Group> 52 + ); 53 + }
+37 -2
src/webapp/features/semble/components/sembleActions/SembleActions.tsx
··· 2 2 3 3 import AddCardToModal from '@/features/cards/components/addCardToModal/AddCardToModal'; 4 4 import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; 5 - import { Button, Group } from '@mantine/core'; 5 + import { ActionIcon, Button, CopyButton, Group, Tooltip } from '@mantine/core'; 6 + import { notifications } from '@mantine/notifications'; 6 7 import { Fragment, useState } from 'react'; 7 8 import { FiPlus } from 'react-icons/fi'; 8 9 import { IoMdCheckmark } from 'react-icons/io'; 10 + import { MdIosShare } from 'react-icons/md'; 9 11 10 12 interface Props { 11 13 url: string; ··· 16 18 const isInYourLibrary = cardStatus.data.card?.urlInLibrary; 17 19 const [showAddToModal, setShowAddToModal] = useState(false); 18 20 21 + const shareLink = 22 + typeof window !== 'undefined' 23 + ? `${window.location.origin}/url?id=${props.url}` 24 + : ''; 25 + 19 26 if (cardStatus.error) { 20 27 return null; 21 28 } 22 29 23 30 return ( 24 31 <Fragment> 25 - <Group> 32 + <Group gap={'xs'}> 33 + <CopyButton value={shareLink}> 34 + {({ copied, copy }) => ( 35 + <Tooltip 36 + label={copied ? 'Link copied!' : 'Share'} 37 + withArrow 38 + position="top" 39 + > 40 + <ActionIcon 41 + variant="light" 42 + color="gray" 43 + size={'xl'} 44 + radius={'xl'} 45 + onClick={() => { 46 + copy(); 47 + 48 + if (copied) return; 49 + notifications.show({ 50 + message: 'Link copied!', 51 + position: 'top-center', 52 + id: copied.toString(), 53 + }); 54 + }} 55 + > 56 + <MdIosShare size={22} /> 57 + </ActionIcon> 58 + </Tooltip> 59 + )} 60 + </CopyButton> 26 61 <Button 27 62 variant={isInYourLibrary ? 'default' : 'filled'} 28 63 size="md"
+1 -1
src/webapp/features/semble/components/sembleTabs/TabItem.tsx
··· 8 8 9 9 export default function TabItem(props: Props) { 10 10 return ( 11 - <TabsTab c={'dark'} value={props.value} className={classes.tab} fw={600}> 11 + <TabsTab value={props.value} className={classes.tab} fw={600}> 12 12 {props.children} 13 13 </TabsTab> 14 14 );
+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>
+4 -19
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
··· 1 1 import SembleHeader from '../../components/SembleHeader/SembleHeader'; 2 - import { Image, Container, Stack, Box } from '@mantine/core'; 3 - import BG from '@/assets/semble-header-bg.webp'; 2 + import { Container, Stack } from '@mantine/core'; 4 3 import { Suspense } from 'react'; 5 4 import SembleTabs from '../../components/sembleTabs/SembleTabs'; 6 5 import SembleHeaderSkeleton from '../../components/SembleHeader/Skeleton.SembleHeader'; 6 + import SembleHeaderBackground from './SembleHeaderBackground'; 7 7 8 8 interface Props { 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 - <Box style={{ position: 'relative', width: '100%' }}> 16 - <Image src={BG.src} alt="bg" fit="cover" w="100%" h={80} /> 17 - 18 - {/* White gradient overlay */} 19 - <Box 20 - style={{ 21 - position: 'absolute', 22 - bottom: 0, 23 - left: 0, 24 - width: '100%', 25 - height: '60%', // fade height 26 - background: 'linear-gradient(to top, white, transparent)', 27 - pointerEvents: 'none', 28 - }} 29 - /> 30 - </Box> 15 + <SembleHeaderBackground /> 31 16 <Container px={'xs'} pb={'xs'} size={'xl'}> 32 17 <Stack gap={'xl'}> 33 18 <Suspense fallback={<SembleHeaderSkeleton />}>
+34
src/webapp/features/semble/containers/sembleContainer/SembleHeaderBackground.tsx
··· 1 + import BG from '@/assets/semble-header-bg.webp'; 2 + import DarkBG from '@/assets/semble-header-bg-dark.webp'; 3 + import { Box, Image } from '@mantine/core'; 4 + 5 + export default function SembleHeaderBackground() { 6 + return ( 7 + <Box style={{ position: 'relative', width: '100%' }}> 8 + <Image 9 + src={DarkBG.src} 10 + alt="bg" 11 + fit="cover" 12 + w="100%" 13 + h={80} 14 + lightHidden 15 + /> 16 + 17 + <Image src={BG.src} alt="bg" fit="cover" w="100%" h={80} darkHidden /> 18 + 19 + {/* White gradient overlay */} 20 + <Box 21 + style={{ 22 + position: 'absolute', 23 + bottom: 0, 24 + left: 0, 25 + width: '100%', 26 + height: '60%', // fade height 27 + background: 28 + 'linear-gradient(to top, var(--mantine-color-body), transparent)', 29 + pointerEvents: 'none', 30 + }} 31 + /> 32 + </Box> 33 + ); 34 + }
+3 -18
src/webapp/features/semble/containers/sembleContainer/Skeleton.SembleContainer.tsx
··· 1 - import { Image, Container, Stack, Box } from '@mantine/core'; 2 - import BG from '@/assets/semble-header-bg.webp'; 1 + import { Container, Stack } from '@mantine/core'; 3 2 import SembleHeaderSkeleton from '../../components/SembleHeader/Skeleton.SembleHeader'; 3 + import SembleHeaderBackground from './SembleHeaderBackground'; 4 4 5 5 export default function SembleContainerSkeleton() { 6 6 return ( 7 7 <Container p={0} fluid> 8 - <Box style={{ position: 'relative', width: '100%' }}> 9 - <Image src={BG.src} alt="bg" fit="cover" w="100%" h={80} /> 10 - 11 - {/* White gradient overlay */} 12 - <Box 13 - style={{ 14 - position: 'absolute', 15 - bottom: 0, 16 - left: 0, 17 - width: '100%', 18 - height: '60%', // fade height 19 - background: 'linear-gradient(to top, white, transparent)', 20 - pointerEvents: 'none', 21 - }} 22 - /> 23 - </Box> 8 + <SembleHeaderBackground /> 24 9 <Container px={'xs'} pb={'xs'} size={'xl'}> 25 10 <Stack gap={'xl'}> 26 11 <SembleHeaderSkeleton />
+1 -1
src/webapp/providers/mantine.tsx
··· 12 12 13 13 export default function MantineProvider(props: Props) { 14 14 return ( 15 - <BaseProvider theme={theme}> 15 + <BaseProvider theme={theme} defaultColorScheme="auto"> 16 16 <Notifications position="bottom-right" /> 17 17 {props.children} 18 18 </BaseProvider>
+4 -1
src/webapp/styles/theme.tsx
··· 11 11 Spoiler, 12 12 TabsTab, 13 13 Tooltip, 14 - Title, 15 14 Text, 16 15 } from '@mantine/core'; 17 16 18 17 export const theme = createTheme({ 18 + primaryShade: { 19 + light: 6, 20 + dark: 6, 21 + }, 19 22 primaryColor: 'tangerine', 20 23 colors: { 21 24 tangerine: [