This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

chore: data sync stories

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