+9
LICENSE
+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
+1
-1
README.md
···
2
2
3
3

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
+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
+2
-2
src/modules/cards/domain/value-objects/URL.ts
+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
+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
+2
-2
src/webapp/app/(auth)/login/page.tsx
+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
+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
+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
+191
-141
src/webapp/app/page.tsx
+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
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
-
234
-
<Text c="dark.1" fw={600} span>
235
-
with support from
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
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>
262
312
);
263
313
}
+10
src/webapp/assets/cosmik-logo-full-white.svg
+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
src/webapp/assets/semble-bg-dark.png
This is a binary file and will not be displayed.
src/webapp/assets/semble-bg-dark.webp
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
src/webapp/assets/semble-header-bg-dark.webp
This is a binary file and will not be displayed.
+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
+2
-2
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
+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
+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/components/createCollectionDrawer/CreateCollectionDrawer.tsx
+1
-1
src/webapp/features/collections/components/createCollectionDrawer/CreateCollectionDrawer.tsx
+1
-1
src/webapp/features/collections/components/editCollectionDrawer/EditCollectionDrawer.tsx
+1
-1
src/webapp/features/collections/components/editCollectionDrawer/EditCollectionDrawer.tsx
+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
+57
-14
src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx
+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
+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
+1
-1
src/webapp/features/notes/components/editNoteDrawer/EditNoteDrawer.tsx
+1
-1
src/webapp/features/notes/components/editNoteDrawer/EditNoteDrawer.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
+60
-11
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
+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
+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
+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
+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
+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
+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
+1
-1
src/webapp/features/semble/components/sembleTabs/TabItem.tsx
+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>
+4
-19
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
+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
+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
+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
+1
-1
src/webapp/providers/mantine.tsx
+4
-1
src/webapp/styles/theme.tsx
+4
-1
src/webapp/styles/theme.tsx