+1
-1
src/modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase.ts
+1
-1
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 {
+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
+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
+1
-1
src/webapp/app/(dashboard)/error.tsx
+1
-1
src/webapp/app/(dashboard)/error.tsx
+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/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
+1
-1
src/webapp/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
+1
-1
src/webapp/features/cards/lib/cardKeys.ts
+1
-1
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],
+1
-1
src/webapp/features/cards/lib/queries/useMyCards.tsx
+1
-1
src/webapp/features/cards/lib/queries/useMyCards.tsx
+1
-1
src/webapp/features/collections/components/collectionCard/CollectionCard.tsx
+1
-1
src/webapp/features/collections/components/collectionCard/CollectionCard.tsx
+1
-1
src/webapp/features/collections/lib/collectionKeys.ts
+1
-1
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
7
infinite: (id?: string, limit?: number) => [
+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/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) => {
+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
src/webapp/features/notes/components/noteCard/NoteCard.tsx
+1
src/webapp/features/notes/components/noteCard/NoteCard.tsx
+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/profileHoverCard/ProfileHoverCard.tsx
+1
-1
src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.tsx
+5
-1
src/webapp/features/semble/containers/sembleAside/SembleAside.tsx
+5
-1
src/webapp/features/semble/containers/sembleAside/SembleAside.tsx