src
webapp
app
features
cards
settings
containers
dataSyncContainer
settingsContainer
lib
···
1
1
+
import BackButton from '@/components/navigation/backButton/BackButton';
2
2
+
import Header from '@/components/navigation/header/Header';
3
3
+
import type { Metadata } from 'next';
4
4
+
import { Fragment } from 'react';
5
5
+
6
6
+
export const metadata: Metadata = {
7
7
+
title: 'Data sync',
8
8
+
description: 'Data sync',
9
9
+
};
10
10
+
11
11
+
interface Props {
12
12
+
children: React.ReactNode;
13
13
+
}
14
14
+
15
15
+
export default function Layout(props: Props) {
16
16
+
return (
17
17
+
<Fragment>
18
18
+
<Header title="Data sync">
19
19
+
<BackButton />
20
20
+
</Header>
21
21
+
{props.children}
22
22
+
</Fragment>
23
23
+
);
24
24
+
}
···
1
1
+
import DataSyncContainerSkeleton from '@/features/settings/containers/dataSyncContainer/Skeleton.DataSyncContainer';
2
2
+
3
3
+
export default function Loading() {
4
4
+
return <DataSyncContainerSkeleton />;
5
5
+
}
···
1
1
+
import DataSyncContainer from '@/features/settings/containers/dataSyncContainer/DataSyncContainer';
2
2
+
3
3
+
export default function Page() {
4
4
+
return <DataSyncContainer />;
5
5
+
}
···
1
1
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
2
+
import { useState } from 'react';
2
3
import { CollectionAccessType } from '@semble/types';
3
4
import type { Collection } from '@semble/types';
4
5
import UrlCard from './UrlCard';
···
186
187
note: {
187
188
id: 'note-002',
188
189
text: 'The foundational transformer paper. Still worth re-reading every few months.',
190
190
+
},
191
191
+
},
192
192
+
};
193
193
+
194
194
+
const ownedCollection: Collection = {
195
195
+
...baseCollection,
196
196
+
id: 'col-story-pin-001',
197
197
+
uri: 'at://did:plc:mock123/app.semble.collection/pinstory',
198
198
+
name: 'My Reading List',
199
199
+
author: {
200
200
+
id: mockAuthUser.id,
201
201
+
name: mockAuthUser.name,
202
202
+
handle: mockAuthUser.handle,
203
203
+
avatarUrl: mockAuthUser.avatarUrl,
204
204
+
},
205
205
+
};
206
206
+
207
207
+
export const PinUnpinInCollection: Story = {
208
208
+
render: (args) => {
209
209
+
const [isPinned, setIsPinned] = useState(false);
210
210
+
return (
211
211
+
<UrlCard
212
212
+
{...args}
213
213
+
isPinnedInCollection={isPinned}
214
214
+
onTogglePinInCollection={() => setIsPinned((prev) => !prev)}
215
215
+
/>
216
216
+
);
217
217
+
},
218
218
+
args: {
219
219
+
id: 'card-pin-toggle-008',
220
220
+
currentCollection: ownedCollection,
221
221
+
cardContent: {
222
222
+
...baseCardContent,
223
223
+
url: 'https://example.com/css-cascade-layers',
224
224
+
title: 'A Practical Look at CSS Cascade Layers',
225
225
+
description:
226
226
+
'Cascade layers give you explicit control over CSS specificity ordering — here is when they help and when they get in the way.',
227
227
+
imageUrl: 'https://picsum.photos/seed/urlcard-pin/400/225',
228
228
+
siteName: 'Example Blog',
229
229
+
},
230
230
+
},
231
231
+
};
232
232
+
233
233
+
export const PinnedInCollection: Story = {
234
234
+
args: {
235
235
+
id: 'card-pinned-009',
236
236
+
currentCollection: ownedCollection,
237
237
+
isPinnedInCollection: true,
238
238
+
onTogglePinInCollection: () => {},
239
239
+
cardContent: {
240
240
+
...baseCardContent,
241
241
+
url: 'https://example.com/the-tao-of-css',
242
242
+
title: 'The Tao of CSS',
243
243
+
description:
244
244
+
'A meditation on simplicity, specificity, and the long road from inline styles to design systems.',
245
245
+
imageUrl: 'https://picsum.photos/seed/urlcard-pinned/400/225',
246
246
+
siteName: 'Example Blog',
189
247
},
190
248
},
191
249
};
···
33
33
showAuthor?: boolean;
34
34
semblePageUrl?: string;
35
35
analyticsContext?: CardSaveAnalyticsContext;
36
36
+
isPinnedInCollection?: boolean;
37
37
+
onTogglePinInCollection?: () => void;
36
38
}
37
39
38
40
export default function UrlCard(props: Props) {
···
169
171
viaCardId={props.viaCardId}
170
172
semblePageUrl={props.semblePageUrl}
171
173
analyticsContext={props.analyticsContext}
174
174
+
isPinnedInCollection={props.isPinnedInCollection}
175
175
+
onTogglePinInCollection={props.onTogglePinInCollection}
172
176
/>
173
177
</Stack>
174
178
</Stack>
···
17
17
} from '@mantine/core';
18
18
import { Fragment, useState } from 'react';
19
19
import { FiPlus } from 'react-icons/fi';
20
20
-
import { BsThreeDots, BsTrash2Fill } from 'react-icons/bs';
20
20
+
import {
21
21
+
BsPinAngle,
22
22
+
BsPinAngleFill,
23
23
+
BsThreeDots,
24
24
+
BsTrash2Fill,
25
25
+
} from 'react-icons/bs';
21
26
import RemoveCardFromCollectionModal from '../removeCardFromCollectionModal/RemoveCardFromCollectionModal';
22
27
import RemoveCardFromLibraryModal from '../removeCardFromLibraryModal/RemoveCardFromLibraryModal';
23
28
import AddCardToModal from '@/features/cards/components/addCardToModal/AddCardToModal';
···
49
54
viaCardId?: string;
50
55
semblePageUrl?: string;
51
56
analyticsContext?: CardSaveAnalyticsContext;
57
57
+
isPinnedInCollection?: boolean;
58
58
+
onTogglePinInCollection?: () => void;
52
59
}
53
60
54
61
export default function UrlCardActions(props: Props) {
···
231
238
)}
232
239
</CopyButton>
233
240
241
241
+
{props.currentCollection &&
242
242
+
props.onTogglePinInCollection &&
243
243
+
isCollectionOwner && (
244
244
+
<Menu.Item
245
245
+
leftSection={
246
246
+
props.isPinnedInCollection ? (
247
247
+
<BsPinAngleFill />
248
248
+
) : (
249
249
+
<BsPinAngle />
250
250
+
)
251
251
+
}
252
252
+
onClick={(e) => {
253
253
+
e.stopPropagation();
254
254
+
trigger();
255
255
+
props.onTogglePinInCollection?.();
256
256
+
}}
257
257
+
>
258
258
+
{props.isPinnedInCollection
259
259
+
? 'Unpin from collection'
260
260
+
: 'Pin to collection'}
261
261
+
</Menu.Item>
262
262
+
)}
234
263
{props.currentCollection &&
235
264
(isAuthor || canRemoveFromOpenCollection) && (
236
265
<Menu.Item
···
1
1
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
2
+
import DataSyncContainer from './DataSyncContainer';
3
3
+
import DataSyncContainerSkeleton from './Skeleton.DataSyncContainer';
4
4
+
import {
5
5
+
MOCK_BOTH_DIRECTIONS,
6
6
+
MOCK_DB_AHEAD,
7
7
+
MOCK_FAILED,
8
8
+
MOCK_PDS_AHEAD,
9
9
+
MOCK_SYNCING,
10
10
+
} from '../../lib/dataSync/mockData';
11
11
+
12
12
+
const meta: Meta<typeof DataSyncContainer> = {
13
13
+
title: 'Features/Settings/DataSyncContainer',
14
14
+
component: DataSyncContainer,
15
15
+
};
16
16
+
17
17
+
export default meta;
18
18
+
19
19
+
type Story = StoryObj<typeof DataSyncContainer>;
20
20
+
21
21
+
/** Healthy state — everything matches between Semble and the PDS. */
22
22
+
export const InSync: Story = {};
23
23
+
24
24
+
/** Records arrived on the PDS that Semble hasn't pulled in yet. */
25
25
+
export const PdsAhead: Story = {
26
26
+
args: {
27
27
+
initialState: MOCK_PDS_AHEAD,
28
28
+
},
29
29
+
};
30
30
+
31
31
+
/** Records exist in Semble that haven't been published to the PDS. */
32
32
+
export const DbAhead: Story = {
33
33
+
args: {
34
34
+
initialState: MOCK_DB_AHEAD,
35
35
+
},
36
36
+
};
37
37
+
38
38
+
/** Drift on both sides — items missing in each direction. */
39
39
+
export const BothDirections: Story = {
40
40
+
args: {
41
41
+
initialState: MOCK_BOTH_DIRECTIONS,
42
42
+
},
43
43
+
};
44
44
+
45
45
+
/** A re-sync is currently running. Button shows the loading state. */
46
46
+
export const Syncing: Story = {
47
47
+
args: {
48
48
+
initialState: MOCK_SYNCING,
49
49
+
},
50
50
+
};
51
51
+
52
52
+
/** Last sync attempt failed — button flips to "Retry" and shows the error. */
53
53
+
export const Failed: Story = {
54
54
+
args: {
55
55
+
initialState: MOCK_FAILED,
56
56
+
},
57
57
+
};
58
58
+
59
59
+
export const Skeleton: Story = {
60
60
+
render: () => <DataSyncContainerSkeleton />,
61
61
+
};
···
1
1
+
'use client';
2
2
+
3
3
+
import {
4
4
+
Badge,
5
5
+
Button,
6
6
+
Card,
7
7
+
Container,
8
8
+
Group,
9
9
+
Progress,
10
10
+
Stack,
11
11
+
Text,
12
12
+
} from '@mantine/core';
13
13
+
import { getRelativeTime } from '@/lib/utils/time';
14
14
+
import { useDataSync } from '../../lib/dataSync/useDataSync';
15
15
+
import type { DataSyncState } from '../../lib/dataSync/types';
16
16
+
17
17
+
interface DataSyncContainerProps {
18
18
+
/**
19
19
+
* Seed the sync state. Defaults to the in-sync mock.
20
20
+
* Remove this prop when wiring up real data via the hook.
21
21
+
* (used by Storybook)
22
22
+
*/
23
23
+
initialState?: DataSyncState;
24
24
+
}
25
25
+
26
26
+
function formatRelative(date: Date | null): string {
27
27
+
if (!date) return 'never';
28
28
+
const relative = getRelativeTime(date.toISOString());
29
29
+
return relative === 'now' ? 'just now' : `${relative} ago`;
30
30
+
}
31
31
+
32
32
+
export default function DataSyncContainer({
33
33
+
initialState,
34
34
+
}: DataSyncContainerProps) {
35
35
+
const {
36
36
+
status,
37
37
+
drift,
38
38
+
lastSyncedAt,
39
39
+
lastSyncAttemptAt,
40
40
+
errorMessage,
41
41
+
recordsProcessed,
42
42
+
resync,
43
43
+
} = useDataSync(initialState);
44
44
+
45
45
+
const isSyncing = status === 'syncing';
46
46
+
const buttonLabel = status === 'failed' ? 'Retry' : 'Re-sync now';
47
47
+
48
48
+
return (
49
49
+
<Container p="xs" size="xs">
50
50
+
<Stack gap="xl">
51
51
+
<Group justify="space-between" align="flex-start" wrap="nowrap">
52
52
+
<Stack gap={0}>
53
53
+
<Text fw={500}>Data sync</Text>
54
54
+
<Text fw={500} c="gray" fz="sm">
55
55
+
Reconcile records between Semble and your PDS
56
56
+
</Text>
57
57
+
</Stack>
58
58
+
<Button
59
59
+
variant="light"
60
60
+
size="sm"
61
61
+
radius="xl"
62
62
+
onClick={resync}
63
63
+
loading={isSyncing}
64
64
+
style={{ flexShrink: 0 }}
65
65
+
>
66
66
+
{buttonLabel}
67
67
+
</Button>
68
68
+
</Group>
69
69
+
70
70
+
<Card bg="var(--mantine-color-gray-light)" radius="lg" p="md">
71
71
+
<Stack gap="sm">
72
72
+
{status === 'in-sync' && (
73
73
+
<>
74
74
+
<Badge color="green" size="sm" w="fit-content">
75
75
+
In sync
76
76
+
</Badge>
77
77
+
<Text fz="sm" fw={500} c="gray">
78
78
+
Everything matches between Semble and your PDS.
79
79
+
</Text>
80
80
+
<Text fz="xs" fw={500} c="dimmed">
81
81
+
Last synced {formatRelative(lastSyncedAt)}
82
82
+
</Text>
83
83
+
</>
84
84
+
)}
85
85
+
86
86
+
{status === 'out-of-sync' && (
87
87
+
<>
88
88
+
<Badge color="orange" size="sm" w="fit-content">
89
89
+
Out of sync
90
90
+
</Badge>
91
91
+
<Stack gap={4}>
92
92
+
{drift.pdsMissing > 0 && (
93
93
+
<Text fz="sm" fw={500} c="bright">
94
94
+
{drift.pdsMissing}{' '}
95
95
+
{drift.pdsMissing === 1 ? 'record' : 'records'} in your
96
96
+
PDS not yet in Semble
97
97
+
</Text>
98
98
+
)}
99
99
+
{drift.dbMissing > 0 && (
100
100
+
<Text fz="sm" fw={500} c="bright">
101
101
+
{drift.dbMissing}{' '}
102
102
+
{drift.dbMissing === 1 ? 'record' : 'records'} in Semble
103
103
+
not yet on your PDS
104
104
+
</Text>
105
105
+
)}
106
106
+
</Stack>
107
107
+
<Text fz="xs" fw={500} c="dimmed">
108
108
+
Last synced {formatRelative(lastSyncedAt)}
109
109
+
</Text>
110
110
+
</>
111
111
+
)}
112
112
+
113
113
+
{status === 'syncing' && (
114
114
+
<>
115
115
+
<Badge color="blue" size="sm" w="fit-content">
116
116
+
Syncing
117
117
+
</Badge>
118
118
+
<Text fz="sm" fw={500} c="gray">
119
119
+
{recordsProcessed}{' '}
120
120
+
{recordsProcessed === 1 ? 'record' : 'records'} processed
121
121
+
</Text>
122
122
+
<Progress value={100} animated size="md" radius="xl" />
123
123
+
</>
124
124
+
)}
125
125
+
126
126
+
{status === 'failed' && (
127
127
+
<>
128
128
+
<Badge color="red" size="sm" w="fit-content">
129
129
+
Failed
130
130
+
</Badge>
131
131
+
{errorMessage && (
132
132
+
<Text fz="sm" fw={500} c="bright">
133
133
+
{errorMessage}
134
134
+
</Text>
135
135
+
)}
136
136
+
<Text fz="xs" fw={500} c="dimmed">
137
137
+
Last attempted {formatRelative(lastSyncAttemptAt)}
138
138
+
</Text>
139
139
+
</>
140
140
+
)}
141
141
+
</Stack>
142
142
+
</Card>
143
143
+
</Stack>
144
144
+
</Container>
145
145
+
);
146
146
+
}
···
1
1
+
import { Card, Container, Group, Skeleton, Stack } from '@mantine/core';
2
2
+
3
3
+
export default function DataSyncContainerSkeleton() {
4
4
+
return (
5
5
+
<Container p="xs" size="xs">
6
6
+
<Stack gap="xl">
7
7
+
<Group justify="space-between" align="flex-start">
8
8
+
<Stack gap={4}>
9
9
+
<Skeleton height={14} width={84} radius="sm" />
10
10
+
<Skeleton height={12} width={240} radius="sm" />
11
11
+
</Stack>
12
12
+
<Skeleton height={36} width={120} radius="xl" />
13
13
+
</Group>
14
14
+
<Card bg="var(--mantine-color-gray-light)" radius="lg" p="md">
15
15
+
<Stack gap="sm">
16
16
+
<Skeleton height={18} width={70} radius="xl" />
17
17
+
<Skeleton height={12} width={260} radius="sm" />
18
18
+
<Skeleton height={10} width={140} radius="sm" />
19
19
+
</Stack>
20
20
+
</Card>
21
21
+
</Stack>
22
22
+
</Container>
23
23
+
);
24
24
+
}
···
7
7
IoMdInformationCircle,
8
8
} from 'react-icons/io';
9
9
import SettingLogoutItem from '../../components/settingLogoutItem/SettingLogoutItem';
10
10
-
import { MdEmojiNature, MdScience } from 'react-icons/md';
10
10
+
import { MdEmojiNature, MdScience, MdSync } from 'react-icons/md';
11
11
import { Suspense } from 'react';
12
12
import AccountSummarySkeleton from '../../components/accountSummary/Skeleton.AccountSummary';
13
13
···
28
28
</SettingItem>
29
29
<SettingItem href="/settings/feed" icon={MdEmojiNature}>
30
30
Feed
31
31
+
</SettingItem>
32
32
+
<SettingItem href="/settings/data-sync" icon={MdSync}>
33
33
+
Data sync
31
34
</SettingItem>
32
35
<SettingItem href="/settings/help" icon={IoMdHelpCircle}>
33
36
Help
···
1
1
+
import type { DataSyncState } from './types';
2
2
+
3
3
+
export const MOCK_IN_SYNC: DataSyncState = {
4
4
+
status: 'in-sync',
5
5
+
drift: { pdsMissing: 0, dbMissing: 0 },
6
6
+
lastSyncedAt: new Date('2026-05-19T08:42:00Z'),
7
7
+
lastSyncAttemptAt: new Date('2026-05-19T08:42:00Z'),
8
8
+
errorMessage: null,
9
9
+
recordsProcessed: 0,
10
10
+
};
11
11
+
12
12
+
export const MOCK_PDS_AHEAD: DataSyncState = {
13
13
+
status: 'out-of-sync',
14
14
+
drift: { pdsMissing: 3, dbMissing: 0 },
15
15
+
lastSyncedAt: new Date('2026-05-18T14:10:00Z'),
16
16
+
lastSyncAttemptAt: new Date('2026-05-18T14:10:00Z'),
17
17
+
errorMessage: null,
18
18
+
recordsProcessed: 0,
19
19
+
};
20
20
+
21
21
+
export const MOCK_DB_AHEAD: DataSyncState = {
22
22
+
status: 'out-of-sync',
23
23
+
drift: { pdsMissing: 0, dbMissing: 2 },
24
24
+
lastSyncedAt: new Date('2026-05-17T22:55:00Z'),
25
25
+
lastSyncAttemptAt: new Date('2026-05-17T22:55:00Z'),
26
26
+
errorMessage: null,
27
27
+
recordsProcessed: 0,
28
28
+
};
29
29
+
30
30
+
export const MOCK_BOTH_DIRECTIONS: DataSyncState = {
31
31
+
status: 'out-of-sync',
32
32
+
drift: { pdsMissing: 3, dbMissing: 2 },
33
33
+
lastSyncedAt: new Date('2026-05-16T09:30:00Z'),
34
34
+
lastSyncAttemptAt: new Date('2026-05-16T09:30:00Z'),
35
35
+
errorMessage: null,
36
36
+
recordsProcessed: 0,
37
37
+
};
38
38
+
39
39
+
export const MOCK_SYNCING: DataSyncState = {
40
40
+
status: 'syncing',
41
41
+
drift: { pdsMissing: 3, dbMissing: 2 },
42
42
+
lastSyncedAt: new Date('2026-05-16T09:30:00Z'),
43
43
+
lastSyncAttemptAt: new Date('2026-05-19T10:00:00Z'),
44
44
+
errorMessage: null,
45
45
+
recordsProcessed: 14,
46
46
+
};
47
47
+
48
48
+
export const MOCK_FAILED: DataSyncState = {
49
49
+
status: 'failed',
50
50
+
drift: { pdsMissing: 3, dbMissing: 2 },
51
51
+
lastSyncedAt: new Date('2026-05-16T09:30:00Z'),
52
52
+
lastSyncAttemptAt: new Date('2026-05-19T09:58:00Z'),
53
53
+
errorMessage: 'Could not reach your PDS at bsky.social',
54
54
+
recordsProcessed: 0,
55
55
+
};
···
1
1
+
export type DataSyncStatus =
2
2
+
| 'in-sync'
3
3
+
| 'out-of-sync'
4
4
+
| 'syncing'
5
5
+
| 'failed';
6
6
+
7
7
+
export interface DataSyncDrift {
8
8
+
/** Records in the user's PDS that are not in the Semble DB. */
9
9
+
pdsMissing: number;
10
10
+
/** Records in the Semble DB that are not yet on the user's PDS. */
11
11
+
dbMissing: number;
12
12
+
}
13
13
+
14
14
+
export interface DataSyncState {
15
15
+
status: DataSyncStatus;
16
16
+
drift: DataSyncDrift;
17
17
+
lastSyncedAt: Date | null;
18
18
+
lastSyncAttemptAt: Date | null;
19
19
+
errorMessage: string | null;
20
20
+
recordsProcessed: number;
21
21
+
}
···
1
1
+
'use client';
2
2
+
3
3
+
// Mock implementation backed by useState + setTimeout.
4
4
+
//
5
5
+
// To connect real data, replace this hook with TanStack Query:
6
6
+
// - Status: useSuspenseQuery({ queryKey: dataSyncKeys.status(), queryFn: () => dal.getSyncStatus() })
7
7
+
// - Resync: useMutation({ mutationFn: () => dal.resync(), onSuccess: () => queryClient.invalidateQueries(...) })
8
8
+
//
9
9
+
// The container's `initialState` prop can be dropped once real data is in place.
10
10
+
11
11
+
import { useCallback, useRef, useState } from 'react';
12
12
+
import type { DataSyncState } from './types';
13
13
+
import { MOCK_IN_SYNC } from './mockData';
14
14
+
15
15
+
export function useDataSync(initialState: DataSyncState = MOCK_IN_SYNC) {
16
16
+
const [state, setState] = useState<DataSyncState>(initialState);
17
17
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
18
18
+
19
19
+
const resync = useCallback(() => {
20
20
+
if (timerRef.current) clearTimeout(timerRef.current);
21
21
+
22
22
+
const attemptedAt = new Date();
23
23
+
setState((prev) => ({
24
24
+
...prev,
25
25
+
status: 'syncing',
26
26
+
lastSyncAttemptAt: attemptedAt,
27
27
+
errorMessage: null,
28
28
+
recordsProcessed: 0,
29
29
+
}));
30
30
+
31
31
+
timerRef.current = setTimeout(() => {
32
32
+
setState({
33
33
+
status: 'in-sync',
34
34
+
drift: { pdsMissing: 0, dbMissing: 0 },
35
35
+
lastSyncedAt: new Date(),
36
36
+
lastSyncAttemptAt: new Date(),
37
37
+
errorMessage: null,
38
38
+
recordsProcessed: 0,
39
39
+
});
40
40
+
}, 1000);
41
41
+
}, []);
42
42
+
43
43
+
return { ...state, resync };
44
44
+
}
···
1
1
/// <reference types="next" />
2
2
/// <reference types="next/image-types/global" />
3
3
-
import "./.next/dev/types/routes.d.ts";
3
3
+
import "./.next/types/routes.d.ts";
4
4
5
5
// NOTE: This file should not be edited
6
6
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.