+2
-2
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
+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
+1
-1
src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts
+10
-7
src/modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase.ts
+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
+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
+1
-1
src/modules/cards/application/useCases/queries/GetUrlMetadataUseCase.ts
+15
-2
src/modules/cards/domain/value-objects/URL.ts
+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
+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
+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
+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¶m2=value2', () => {
146
+
const result = URL.create(
147
+
'https://example.com?param1=value1¶m2=value2',
148
+
);
149
+
150
+
expect(result.isOk()).toBe(true);
151
+
expect(result.unwrap().value).toBe(
152
+
'https://example.com?param1=value1¶m2=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
+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
+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
+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
+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
+1
-1
src/webapp/app/layout.tsx
+182
-152
src/webapp/app/page.tsx
+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
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
-
254
-
<Text c="dark.1" fw={600} span>
255
-
with support from
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
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
+
288
+
<Text c="dark.1" fw={600} span>
289
+
with support from
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
}
+2
-2
src/webapp/features/auth/components/loginForm/LoginForm.tsx
+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
+1
-1
src/webapp/features/auth/components/signUpForm/SignUpForm.tsx
+1
-1
src/webapp/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
+1
-1
src/webapp/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
+1
-1
src/webapp/features/cards/components/urlCard/UrlCard.tsx
+1
-1
src/webapp/features/cards/components/urlCard/UrlCard.tsx
+3
-2
src/webapp/features/cards/lib/cardKeys.ts
+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
+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
+1
-1
src/webapp/features/cards/lib/queries/useMyCards.tsx
+2
-2
src/webapp/features/collections/components/collectionCard/CollectionCard.tsx
+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>
+1
-1
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
+1
-1
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
+7
-2
src/webapp/features/collections/lib/collectionKeys.ts
+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
+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
+1
-1
src/webapp/features/collections/lib/queries/useCollection.tsx
+1
-1
src/webapp/features/collections/lib/queries/useCollections.tsx
+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
+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
+9
src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.module.css
+18
-19
src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx
+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
+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
+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
+1
-1
src/webapp/features/home/containers/homeContainer/Skeleton.HomeContainer.tsx
+8
-4
src/webapp/features/notes/components/noteCard/NoteCard.tsx
+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
+1
-1
src/webapp/features/notes/components/noteCardModal/NoteCardModalContent.tsx
+1
-1
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
+1
-1
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
+1
-1
src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.tsx
+1
-1
src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.tsx
+15
-15
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
+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
+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
+1
-1
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
+4
-6
src/webapp/features/semble/containers/sembleContainer/SembleHeaderBackground.tsx
+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
+1
-1
src/webapp/providers/mantine.tsx