A social knowledge tool for researchers built on ATProto

Merge pull request #175 from cosmik-network/development

Development

authored by Wesley Finck and committed by GitHub 2027c113 34461fd1

Changed files
+721 -195
src
webapp
.storybook
app
(auth)
login
(dashboard)
profile
[handle]
(withHeader)
url
assets
components
navigation
appLayout
bottomBar
bottomBarItem
guestAppLayout
guestBottomBar
header
navbar
features
cards
collections
components
collectionCard
collectionNavItem
collectionSelector
createCollectionDrawer
editCollectionDrawer
composer
components
composerDrawer
feeds
components
feedActivityStatus
feedItem
notes
components
lib
mutations
profile
semble
providers
styles
+2 -2
src/webapp/.storybook/preview.tsx
··· 1 1 import '@mantine/core/styles.css'; 2 2 3 - import React, { useEffect } from 'react'; 4 - import { MantineProvider, useMantineColorScheme } from '@mantine/core'; 3 + import React from 'react'; 4 + import { MantineProvider } from '@mantine/core'; 5 5 import { theme } from '../styles/theme'; 6 6 import { Hanken_Grotesk } from 'next/font/google'; 7 7
+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
+11 -2
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'; 19 + import { useColorScheme } from '@mantine/hooks'; 18 20 19 21 export default function Error() { 22 + const colorScheme = useColorScheme(); 23 + 20 24 return ( 21 25 <BackgroundImage 22 - src={BG.src} 26 + src={colorScheme === 'dark' ? DarkBG.src : BG.src} 23 27 h={'100svh'} 24 28 pos={'fixed'} 25 29 top={0} ··· 44 48 <Text fz={'h1'} fw={600} ta={'center'}> 45 49 A social knowledge network for researchers 46 50 </Text> 47 - <Text fz={'h3'} fw={600} c={'#1F6144'} ta={'center'}> 51 + <Text 52 + fz={'h3'} 53 + fw={600} 54 + c={colorScheme === 'dark' ? '#1e4dd9' : '#1F6144'} 55 + ta={'center'} 56 + > 48 57 Follow your peers’ research trails. Surface and discover new 49 58 connections. Built on ATProto so you own your data. 50 59 </Text>
+1 -1
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx
··· 42 42 zIndex: 1, 43 43 }} 44 44 > 45 - <Container bg={'white'} px={'xs'} mt={'md'} size={'xl'}> 45 + <Container px={'xs'} mt={'md'} size={'xl'}> 46 46 <ProfileTabs handle={handle} /> 47 47 </Container> 48 48 </Box>
+5
src/webapp/app/(dashboard)/url/loading.tsx
··· 1 + import SembleContainerSkeleton from '@/features/semble/containers/sembleContainer/Skeleton.SembleContainer'; 2 + 3 + export default function Loading() { 4 + return <SembleContainerSkeleton />; 5 + }
+1 -1
src/webapp/app/layout.tsx
··· 26 26 {...mantineHtmlProps} 27 27 > 28 28 <head> 29 - <ColorSchemeScript /> 29 + <ColorSchemeScript forceColorScheme="light" /> 30 30 </head> 31 31 <body className={GlobalStyles.main}> 32 32 <Providers>{children}</Providers>
+23 -3
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'; ··· 26 30 import TangledIcon from '@/assets/icons/tangled-icon.svg'; 27 31 import SembleLogo from '@/assets/semble-logo.svg'; 28 32 import Link from 'next/link'; 33 + import { useColorScheme } from '@mantine/hooks'; 29 34 30 35 export default function Home() { 36 + const colorScheme = useColorScheme(); 37 + 31 38 return ( 32 - <BackgroundImage src={BG.src} h={'100svh'}> 39 + <BackgroundImage 40 + src={colorScheme === 'dark' ? DarkBG.src : BG.src} 41 + h={'100svh'} 42 + > 33 43 <script async src="https://tally.so/widgets/embed.js" /> 34 44 <Container size={'xl'} p={'md'} my={'auto'}> 35 45 <Group justify="space-between"> ··· 56 66 <Title order={1} fw={600} fz={'3rem'} ta={'center'}> 57 67 A social knowledge network for researchers 58 68 </Title> 59 - <Title order={2} fw={600} fz={'xl'} c={'#1F6144'} ta={'center'}> 69 + <Title 70 + order={2} 71 + fw={600} 72 + fz={'xl'} 73 + c={colorScheme === 'dark' ? '#1e4dd9' : '#1F6144'} 74 + ta={'center'} 75 + > 60 76 Follow your peers’ research trails. Surface and discover new 61 77 connections. Built on ATProto so you own your data. 62 78 </Title> ··· 223 239 style={{ verticalAlign: 'middle' }} 224 240 > 225 241 <Image 226 - src={CosmikLogo.src} 242 + src={ 243 + colorScheme === 'dark' 244 + ? CosmikLogoWhite.src 245 + : CosmikLogo.src 246 + } 227 247 alt="Cosmik logo" 228 248 w={92} 229 249 h={28.4}
+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

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

-1
src/webapp/components/AddToCollectionModal.tsx
··· 120 120 onClose={handleClose} 121 121 title="Add to Collections" 122 122 centered 123 - size="md" 124 123 > 125 124 <Stack p="sm"> 126 125 {loading ? (
+1 -1
src/webapp/components/navigation/appLayout/AppLayout.tsx
··· 14 14 15 15 export default function AppLayout(props: Props) { 16 16 const { mobileOpened, desktopOpened } = useNavbarContext(); 17 - const isMobile = useMediaQuery('(max-width: 48em)'); // "sm" breakpoint 17 + const isMobile = useMediaQuery('(max-width: 48em)', true); // "sm" breakpoint 18 18 const pathname = usePathname(); 19 19 20 20 const ROUTES_WITH_ASIDE = ['/url'];
-1
src/webapp/components/navigation/bottomBar/BottomBar.tsx
··· 2 2 import { FaRegNoteSticky } from 'react-icons/fa6'; 3 3 import { LuLibrary } from 'react-icons/lu'; 4 4 import { MdOutlineEmojiNature } from 'react-icons/md'; 5 - import NavbarToggle from '../NavbarToggle'; 6 5 import BottomBarItem from '../bottomBarItem/BottomBarItem'; 7 6 import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 8 7
+3 -2
src/webapp/components/navigation/bottomBarItem/BottomBarItem.tsx
··· 3 3 import { usePathname } from 'next/navigation'; 4 4 import Link from 'next/link'; 5 5 import { ReactElement, isValidElement } from 'react'; 6 + import { useColorScheme } from '@mantine/hooks'; 6 7 7 8 interface Props { 8 9 href: string; ··· 10 11 } 11 12 12 13 export default function BottomBarItem(props: Props) { 14 + const colorScheme = useColorScheme(); 13 15 const pathname = usePathname(); 14 16 const isActive = pathname === props.href; 15 17 ··· 28 30 href={props.href} 29 31 variant={isActive ? 'light' : 'transparent'} 30 32 size={'lg'} 31 - bg={isActive ? 'gray.1' : 'transparent'} 32 - color={isActive ? 'dark' : 'gray'} 33 + color="gray" 33 34 > 34 35 {renderIcon()} 35 36 </ActionIcon>
+3
src/webapp/components/navigation/guestAppLayout/GuestAppLayout.tsx
··· 4 4 import { useNavbarContext } from '@/providers/navbar'; 5 5 import { usePathname } from 'next/navigation'; 6 6 import GuestNavbar from '../guestNavbar/GuestNavbar'; 7 + import GuestBottomBar from '../guestBottomBar/GuestBottomBar'; 7 8 8 9 interface Props { 9 10 children: React.ReactNode; ··· 36 37 <GuestNavbar /> 37 38 38 39 <AppShell.Main>{props.children}</AppShell.Main> 40 + 41 + <GuestBottomBar /> 39 42 </AppShell> 40 43 ); 41 44 }
+17
src/webapp/components/navigation/guestBottomBar/GuestBottomBar.tsx
··· 1 + import { AppShellFooter, Avatar, Group } from '@mantine/core'; 2 + import { LuLibrary } from 'react-icons/lu'; 3 + import { MdOutlineEmojiNature } from 'react-icons/md'; 4 + import BottomBarItem from '../bottomBarItem/BottomBarItem'; 5 + import Link from 'next/link'; 6 + 7 + export default function GuestBottomBar() { 8 + return ( 9 + <AppShellFooter px={'sm'} pb={'lg'} py={'xs'} hiddenFrom="sm"> 10 + <Group align="start" justify="space-around" gap={'lg'} h={'100%'}> 11 + <BottomBarItem href="/home" icon={LuLibrary} /> 12 + <BottomBarItem href="/explore" icon={MdOutlineEmojiNature} /> 13 + <Avatar component={Link} href={'/login'} /> 14 + </Group> 15 + </AppShellFooter> 16 + ); 17 + }
+3 -3
src/webapp/components/navigation/header/Header.tsx
··· 1 - import { Box, Divider, Group } from '@mantine/core'; 1 + import { Box, Divider, Group, Paper } from '@mantine/core'; 2 2 import NavbarToggle from '../NavbarToggle'; 3 3 import { ReactElement } from 'react'; 4 4 ··· 8 8 9 9 export default function Header(props: Props) { 10 10 return ( 11 - <Box pos={'sticky'} top={0} bg={'white'} style={{ zIndex: 1 }}> 11 + <Paper pos={'sticky'} top={0} style={{ zIndex: 1 }}> 12 12 <Group gap={'xs'} p={'xs'} justify="space-between"> 13 13 {props.children} 14 14 <Box ml={'auto'}> ··· 16 16 </Box> 17 17 </Group> 18 18 <Divider /> 19 - </Box> 19 + </Paper> 20 20 ); 21 21 }
+3 -1
src/webapp/components/navigation/navbar/Navbar.tsx
··· 36 36 <Anchor component={Link} href={'/home'}> 37 37 <Stack align="center" gap={6}> 38 38 <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} /> 39 - <Badge size="xs">Alpha</Badge> 39 + <Badge size="xs" style={{ cursor: 'pointer' }}> 40 + Alpha 41 + </Badge> 40 42 </Stack> 41 43 </Anchor> 42 44 <Box hiddenFrom="xs">
+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}
+39 -17
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 1 - import type { UrlCard } from '@/api-client'; 2 1 import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 3 2 import { Modal, Stack, Text } from '@mantine/core'; 4 3 import { Suspense } from 'react'; 5 4 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 6 - import AddCardToModalContent from './AddCardToModalContent'; // new file or inline 5 + import AddCardToModalContent from './AddCardToModalContent'; 7 6 8 7 interface Props { 9 8 isOpen: boolean; 10 9 onClose: () => void; 11 - cardContent: UrlCard['cardContent']; 12 - urlLibraryCount: number; 13 - cardId: string; 10 + url: string; 11 + cardId?: string; 14 12 note?: string; 15 - isInYourLibrary: boolean; 13 + urlLibraryCount?: number; 14 + isInYourLibrary?: boolean; 16 15 } 17 16 18 17 export default function AddCardToModal(props: Props) { 18 + const { 19 + isOpen, 20 + onClose, 21 + url, 22 + cardId, 23 + note, 24 + urlLibraryCount, 25 + isInYourLibrary, 26 + } = props; 27 + 28 + const count = urlLibraryCount ?? 0; 29 + 30 + const subtitle = (() => { 31 + if (count === 0) return 'Not saved by anyone yet'; 32 + 33 + if (isInYourLibrary) { 34 + if (count === 1) return 'Saved by you'; 35 + return `Saved by you and ${count - 1} other${count - 1 > 1 ? 's' : ''}`; 36 + } else { 37 + if (count === 1) return 'Saved by 1 person'; 38 + return `Saved by ${count} people`; 39 + } 40 + })(); 41 + 19 42 return ( 20 43 <Modal 21 - opened={props.isOpen} 22 - onClose={props.onClose} 44 + opened={isOpen} 45 + onClose={onClose} 23 46 title={ 24 47 <Stack gap={0}> 25 - <Text fw={600}>Add or update card</Text> 48 + <Text fw={600}>Add or update {props.cardId ? 'card' : 'link'}</Text> 26 49 <Text c="gray" fw={500}> 27 - {props.isInYourLibrary 28 - ? props.urlLibraryCount === 1 29 - ? 'Saved by you' 30 - : `Saved by you and ${props.urlLibraryCount - 1} other${props.urlLibraryCount - 1 > 1 ? 's' : ''}` 31 - : props.urlLibraryCount === 1 32 - ? 'Saved by 1 person' 33 - : `Saved by ${props.urlLibraryCount} people`} 50 + {subtitle} 34 51 </Text> 35 52 </Stack> 36 53 } ··· 39 56 onClick={(e) => e.stopPropagation()} 40 57 > 41 58 <Suspense fallback={<CollectionSelectorSkeleton />}> 42 - <AddCardToModalContent {...props} /> 59 + <AddCardToModalContent 60 + onClose={onClose} 61 + url={url} 62 + cardId={cardId} 63 + note={note} 64 + /> 43 65 </Suspense> 44 66 </Modal> 45 67 );
+14 -8
src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx
··· 11 11 import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; 12 12 import useUpdateCardAssociations from '@/features/cards/lib/mutations/useUpdateCardAssociations'; 13 13 import useAddCard from '@/features/cards/lib/mutations/useAddCard'; 14 + import useUrlMetadata from '../../lib/queries/useUrlMetadata'; 14 15 15 16 interface SelectableCollectionItem { 16 17 id: string; ··· 20 21 21 22 interface Props { 22 23 onClose: () => void; 23 - cardContent: UrlCard['cardContent']; 24 - urlLibraryCount: number; 25 - cardId: string; 24 + url: string; 25 + cardId?: string; 26 26 note?: string; 27 - isInYourLibrary: boolean; 28 27 } 29 28 30 29 export default function AddCardToModalContent(props: Props) { 31 - const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 32 - const isMyCard = props.cardId === cardStatus.data.card?.id; 30 + const { 31 + data: { metadata }, 32 + } = useUrlMetadata({ url: props.url }); 33 + const cardStatus = useGetCardFromMyLibrary({ url: props.url }); 34 + const isMyCard = props?.cardId === cardStatus.data.card?.id; 33 35 const [note, setNote] = useState(isMyCard ? props.note : ''); 34 36 const { data, error } = useMyCollections(); 35 37 ··· 79 81 if (!cardStatus.data.card) { 80 82 addCard.mutate( 81 83 { 82 - url: props.cardContent.url, 84 + url: props.url, 83 85 note: trimmedNote, 84 86 collectionIds: selectedCollections.map((c) => c.id), 85 87 }, ··· 124 126 return ( 125 127 <Stack justify="space-between"> 126 128 <CardToBeAddedPreview 127 - cardContent={props.cardContent} 129 + url={props.url} 130 + thumbnailUrl={metadata.imageUrl} 131 + title={metadata.title} 128 132 note={isMyCard ? note : cardStatus.data.card?.note?.text} 133 + noteId={cardStatus.data.card?.note?.id} 129 134 onUpdateNote={setNote} 135 + onClose={props.onClose} 130 136 /> 131 137 132 138 <CollectionSelector
+99 -37
src/webapp/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
··· 12 12 } from '@mantine/core'; 13 13 import Link from 'next/link'; 14 14 import { Dispatch, SetStateAction, useState } from 'react'; 15 - import { UrlCard } from '@/api-client'; 16 15 import { getDomain } from '@/lib/utils/link'; 16 + import useRemoveCardFromLibrary from '../../lib/mutations/useRemoveCardFromLibrary'; 17 + import { notifications } from '@mantine/notifications'; 17 18 18 19 interface Props { 19 - cardContent: UrlCard['cardContent']; 20 + url: string; 21 + thumbnailUrl?: string; 22 + title?: string; 20 23 note?: string; 24 + noteId?: string; 21 25 onUpdateNote: Dispatch<SetStateAction<string | undefined>>; 26 + onClose: () => void; 22 27 } 23 28 24 29 export default function CardToBeAddedPreview(props: Props) { 30 + const [showDeleteWarning, setShowDeleteWarning] = useState(false); 25 31 const [noteMode, setNoteMode] = useState(false); 26 32 const [note, setNote] = useState(props.note); 27 - const domain = getDomain(props.cardContent.url); 33 + const domain = getDomain(props.url); 34 + 35 + const removeNote = useRemoveCardFromLibrary(); 36 + 37 + const handleDeleteNote = () => { 38 + if (!props.noteId) return; 39 + 40 + removeNote.mutate(props.noteId, { 41 + onError: () => { 42 + notifications.show({ 43 + message: 'Could not delete note.', 44 + position: 'top-center', 45 + }); 46 + }, 47 + onSettled: () => { 48 + props.onClose(); 49 + }, 50 + }); 51 + }; 28 52 29 53 if (noteMode) { 30 54 return ( ··· 74 98 } 75 99 76 100 return ( 77 - <Card withBorder component="article" p={'xs'} radius={'lg'}> 78 - <Stack> 79 - <Group gap={'sm'} justify="space-between"> 80 - {props.cardContent.thumbnailUrl && ( 81 - <AspectRatio ratio={1 / 1} flex={0.1}> 82 - <Image 83 - src={props.cardContent.thumbnailUrl} 84 - alt={`${props.cardContent.url} social preview image`} 85 - radius={'md'} 86 - w={50} 87 - h={50} 88 - /> 89 - </AspectRatio> 90 - )} 91 - <Stack gap={0} flex={0.9}> 92 - <Tooltip label={props.cardContent.url}> 93 - <Anchor 94 - component={Link} 95 - href={props.cardContent.url} 96 - target="_blank" 97 - c={'gray'} 98 - lineClamp={1} 99 - onClick={(e) => e.stopPropagation()} 100 - > 101 - {domain} 102 - </Anchor> 103 - </Tooltip> 104 - {props.cardContent.title && ( 105 - <Text fw={500} lineClamp={1}> 106 - {props.cardContent.title} 107 - </Text> 101 + <Stack gap={'xs'}> 102 + <Card withBorder component="article" p={'xs'} radius={'lg'}> 103 + <Stack> 104 + <Group gap={'sm'} justify="space-between"> 105 + {props.thumbnailUrl && ( 106 + <AspectRatio ratio={1 / 1} flex={0.1}> 107 + <Image 108 + src={props.thumbnailUrl} 109 + alt={`${props.url} social preview image`} 110 + radius={'md'} 111 + w={50} 112 + h={50} 113 + /> 114 + </AspectRatio> 108 115 )} 109 - </Stack> 116 + <Stack gap={0} flex={0.9}> 117 + <Tooltip label={props.url}> 118 + <Anchor 119 + component={Link} 120 + href={props.url} 121 + target="_blank" 122 + c={'gray'} 123 + lineClamp={1} 124 + onClick={(e) => e.stopPropagation()} 125 + > 126 + {domain} 127 + </Anchor> 128 + </Tooltip> 129 + {props.title && ( 130 + <Text fw={500} lineClamp={1}> 131 + {props.title} 132 + </Text> 133 + )} 134 + </Stack> 135 + </Group> 136 + </Stack> 137 + </Card> 138 + {showDeleteWarning ? ( 139 + <Group justify="space-between" gap={'xs'}> 140 + <Text>Delete note?</Text> 141 + <Group gap={'xs'}> 142 + <Button 143 + color="red" 144 + onClick={handleDeleteNote} 145 + loading={removeNote.isPending} 146 + > 147 + Delete 148 + </Button> 149 + <Button 150 + variant="light" 151 + color="gray" 152 + onClick={() => setShowDeleteWarning(false)} 153 + > 154 + Cancel 155 + </Button> 156 + </Group> 157 + </Group> 158 + ) : ( 159 + <Group gap={'xs'}> 110 160 <Button 111 161 variant="light" 112 162 color="gray" ··· 117 167 > 118 168 {note ? 'Edit note' : 'Add note'} 119 169 </Button> 170 + {props.noteId && ( 171 + <Button 172 + variant="light" 173 + color="red" 174 + onClick={(e) => { 175 + e.stopPropagation(); 176 + setShowDeleteWarning(true); 177 + }} 178 + > 179 + Delete note 180 + </Button> 181 + )} 120 182 </Group> 121 - </Stack> 122 - </Card> 183 + )} 184 + </Stack> 123 185 ); 124 186 }
+1 -2
src/webapp/features/cards/components/urlCardActions/UrlCardActions.tsx
··· 32 32 const isAuthor = props.authorHandle 33 33 ? user?.handle === props.authorHandle 34 34 : true; 35 - const [showEditNoteModal, setShowEditNoteModal] = useState(false); 36 35 const [showNoteModal, setShowNoteModal] = useState(false); 37 36 const [showRemoveFromCollectionModal, setShowRemoveFromCollectionModal] = 38 37 useState(false); ··· 119 118 <AddCardToModal 120 119 isOpen={showAddToModal} 121 120 onClose={() => setShowAddToModal(false)} 122 - cardContent={props.cardContent} 121 + url={props.cardContent.url} 123 122 cardId={props.id} 124 123 note={props.note?.text} 125 124 urlLibraryCount={props.urlLibraryCount}
+6
src/webapp/features/cards/lib/mutations/useAddCard.tsx
··· 2 2 import { addUrlToLibrary } from '../dal'; 3 3 import { cardKeys } from '../cardKeys'; 4 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 + import { feedKeys } from '@/features/feeds/lib/feedKeys'; 5 6 6 7 export default function useAddCard() { 7 8 const queryClient = useQueryClient(); ··· 24 25 onSuccess: (_data, variables) => { 25 26 queryClient.invalidateQueries({ queryKey: cardKeys.mine() }); 26 27 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 28 + queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 27 29 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 28 30 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 31 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 29 32 queryClient.invalidateQueries({ 30 33 queryKey: collectionKeys.bySembleUrl(variables.url), 31 34 }); ··· 34 37 variables.collectionIds?.forEach((id) => { 35 38 queryClient.invalidateQueries({ 36 39 queryKey: collectionKeys.collection(id), 40 + }); 41 + queryClient.invalidateQueries({ 42 + queryKey: collectionKeys.infinite(id), 37 43 }); 38 44 }); 39 45 },
+4
src/webapp/features/cards/lib/mutations/useRemoveCardFromCollections.tsx
··· 19 19 onSuccess: (_data, variables) => { 20 20 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 21 21 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 22 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 22 23 23 24 variables.collectionIds.forEach((id) => { 24 25 queryClient.invalidateQueries({ 25 26 queryKey: collectionKeys.collection(id), 27 + }); 28 + queryClient.invalidateQueries({ 29 + queryKey: collectionKeys.infinite(id), 26 30 }); 27 31 }); 28 32 },
+4
src/webapp/features/cards/lib/mutations/useRemoveCardFromLibrary.tsx
··· 2 2 import { removeCardFromLibrary } from '../dal'; 3 3 import { cardKeys } from '../cardKeys'; 4 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 + import { noteKeys } from '@/features/notes/lib/noteKeys'; 6 + import { feedKeys } from '@/features/feeds/lib/feedKeys'; 5 7 6 8 export default function useRemoveCardFromLibrary() { 7 9 const queryClient = useQueryClient(); ··· 13 15 14 16 onSuccess: () => { 15 17 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 18 + queryClient.invalidateQueries({ queryKey: noteKeys.all() }); 19 + queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 16 20 queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 17 21 }, 18 22 });
+3
src/webapp/features/cards/lib/mutations/useUpdateCardAssociations.tsx
··· 4 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 5 import { noteKeys } from '@/features/notes/lib/noteKeys'; 6 6 import { sembleKeys } from '@/features/semble/lib/sembleKeys'; 7 + import { feedKeys } from '@/features/feeds/lib/feedKeys'; 7 8 8 9 export default function useUpdateCardAssociations() { 9 10 const client = createSembleClient(); ··· 28 29 onSuccess: (_data, variables) => { 29 30 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 30 31 queryClient.invalidateQueries({ queryKey: noteKeys.all() }); 32 + queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 31 33 queryClient.invalidateQueries({ queryKey: sembleKeys.all() }); 32 34 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 33 35 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 36 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 34 37 35 38 // invalidate each collection query individually 36 39 variables.addToCollectionIds?.forEach((id) => {
+14
src/webapp/features/cards/lib/queries/useUrlMetadata.tsx
··· 1 + import { useSuspenseQuery } from '@tanstack/react-query'; 2 + import { getUrlMetadata } from '../dal'; 3 + 4 + interface Props { 5 + url: string; 6 + } 7 + 8 + export default function useUrlMetadata(props: Props) { 9 + const metadata = useSuspenseQuery({ 10 + queryKey: [props.url], 11 + queryFn: () => getUrlMetadata(props.url), 12 + }); 13 + return metadata; 14 + }
+3
src/webapp/features/collections/components/collectionCard/CollectionCard.module.css
··· 1 + .root { 2 + cursor: pointer; 3 + }
+2 -1
src/webapp/features/collections/components/collectionCard/CollectionCard.tsx
··· 4 4 import { getRecordKey } from '@/lib/utils/atproto'; 5 5 import { getRelativeTime } from '@/lib/utils/time'; 6 6 import { Avatar, Card, Group, Stack, Text } from '@mantine/core'; 7 + import styles from './CollectionCard.module.css'; 7 8 import { useRouter } from 'next/navigation'; 8 9 9 10 interface Props { ··· 29 30 } 30 31 radius={'lg'} 31 32 p={'sm'} 32 - style={{ cursor: 'pointer' }} 33 + className={styles.root} 33 34 > 34 35 <Stack justify="space-between" h={'100%'}> 35 36 <Stack gap={0}>
+6 -2
src/webapp/features/collections/components/collectionNavItem/CollectionNavItem.tsx
··· 1 1 import { useNavbarContext } from '@/providers/navbar'; 2 2 import { Badge, NavLink } from '@mantine/core'; 3 + import { useColorScheme } from '@mantine/hooks'; 3 4 import Link from 'next/link'; 4 5 import { usePathname } from 'next/navigation'; 5 6 ··· 11 12 12 13 export default function CollectionNavItem(props: Props) { 13 14 const { toggleMobile } = useNavbarContext(); 15 + const colorScheme = useColorScheme(); 14 16 const pathname = usePathname(); 15 17 const isActive = pathname === props.url; 16 18 ··· 20 22 href={props.url} 21 23 label={props.name} 22 24 variant="subtle" 23 - c={isActive ? 'dark' : 'gray'} 25 + c={isActive ? `${colorScheme === 'dark' ? 'white' : 'dark'}` : 'gray'} 24 26 onClick={toggleMobile} 25 27 rightSection={ 26 28 props.cardCount > 0 ? ( 27 29 <Badge 28 30 variant={isActive ? 'filled' : 'light'} 29 - color={isActive ? 'dark' : 'gray'} 31 + color={ 32 + isActive ? `${colorScheme === 'dark' ? 'gray' : 'dark'}` : 'gray' 33 + } 30 34 circle 31 35 > 32 36 {props.cardCount}
+3 -1
src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx
··· 11 11 Button, 12 12 Group, 13 13 Divider, 14 + FocusTrap, 14 15 } from '@mantine/core'; 15 16 import { Fragment, useState } from 'react'; 16 17 import { useDebouncedValue } from '@mantine/hooks'; ··· 73 74 74 75 return ( 75 76 <Fragment> 77 + <FocusTrap.InitialFocus /> 76 78 <Stack gap="xl"> 77 79 <Stack> 78 80 <TextInput ··· 92 94 } 93 95 /> 94 96 95 - <ScrollArea.Autosize mah={340} type="auto"> 97 + <ScrollArea.Autosize mah={215} type="auto"> 96 98 <Stack gap="xs"> 97 99 {search ? ( 98 100 <>
+1 -1
src/webapp/features/collections/components/createCollectionDrawer/CreateCollectionDrawer.tsx
··· 78 78 </Drawer.Title> 79 79 </Drawer.Header> 80 80 81 - <Container size={'sm'}> 81 + <Container size={'sm'} p={0}> 82 82 <form onSubmit={handleCreateCollection}> 83 83 <Stack> 84 84 <TextInput
+1 -1
src/webapp/features/collections/components/editCollectionDrawer/EditCollectionDrawer.tsx
··· 72 72 </Drawer.Title> 73 73 </Drawer.Header> 74 74 75 - <Container size="sm"> 75 + <Container size="sm" p={0}> 76 76 <form onSubmit={handleUpdateCollection}> 77 77 <Stack> 78 78 <TextInput
+1 -1
src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
··· 7 7 8 8 export default function ComposerDrawer() { 9 9 const { mobileOpened, desktopOpened } = useNavbarContext(); 10 - const isDesktop = useMediaQuery('(min-width: 36em)'); // "sm" breakpoint 10 + const isDesktop = useMediaQuery('(min-width: 36em)', false); // "sm" breakpoint 11 11 const isNavOpen = isDesktop ? desktopOpened : mobileOpened; 12 12 const shouldShowFab = !isNavOpen; 13 13 const [opened, setOpened] = useState(false);
+55 -7
src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx
··· 1 - import { Anchor, Avatar, Group, Paper, Stack, Text } from '@mantine/core'; 1 + 'use client'; 2 + 3 + import { 4 + Anchor, 5 + Avatar, 6 + Card, 7 + Group, 8 + Menu, 9 + ScrollArea, 10 + Stack, 11 + Text, 12 + } from '@mantine/core'; 2 13 import { FeedItem, Collection } from '@/api-client'; 3 14 import { Fragment } from 'react'; 4 15 import Link from 'next/link'; 5 16 import { getRelativeTime } from '@/lib/utils/time'; 6 17 import { getRecordKey } from '@/lib/utils/atproto'; 7 18 import { sanitizeText } from '@/lib/utils/text'; 19 + import { useColorScheme } from '@mantine/hooks'; 20 + import { BiCollection } from 'react-icons/bi'; 8 21 9 22 interface Props { 10 23 user: FeedItem['user']; ··· 13 26 } 14 27 15 28 export default function FeedActivityStatus(props: Props) { 29 + const colorScheme = useColorScheme(); 16 30 const MAX_DISPLAYED = 2; 17 31 const time = getRelativeTime(props.createdAt.toString()); 18 32 const relativeCreatedDate = time === 'just now' ? `Now` : `${time} ago`; ··· 20 34 const renderActivityText = () => { 21 35 const collections = props.collections ?? []; 22 36 const displayedCollections = collections.slice(0, MAX_DISPLAYED); 37 + const remainingCollections = collections.slice( 38 + MAX_DISPLAYED, 39 + collections.length, 40 + ); 23 41 const remainingCount = collections.length - MAX_DISPLAYED; 24 42 25 43 return ( 26 - <Text fw={500} c={'gray.7'}> 44 + <Text fw={500} c={'gray'}> 27 45 <Anchor 28 46 component={Link} 29 47 href={`/profile/${props.user.handle}`} 30 - c="blue" 48 + c="dark" 31 49 fw={600} 32 50 > 33 51 {sanitizeText(props.user.name)} ··· 52 70 </span> 53 71 ), 54 72 )} 55 - {remainingCount > 0 && 56 - ` and ${remainingCount} other collection${remainingCount > 1 ? 's' : ''}`} 73 + {remainingCount > 0 && ' and '} 74 + {remainingCount > 0 && ( 75 + <Menu shadow="sm"> 76 + <Menu.Target> 77 + <Text 78 + fw={600} 79 + c={'blue'} 80 + style={{ cursor: 'pointer', userSelect: 'none' }} 81 + span 82 + > 83 + {remainingCount} other collection 84 + {remainingCount > 1 ? 's' : ''} 85 + </Text> 86 + </Menu.Target> 87 + <Menu.Dropdown maw={380}> 88 + <ScrollArea.Autosize mah={150} type="auto"> 89 + {remainingCollections.map((c) => ( 90 + <Menu.Item 91 + key={c.id} 92 + component={Link} 93 + href={`/profile/${c.author.handle}/collections/${getRecordKey(c.uri!)}`} 94 + target="_blank" 95 + c="blue" 96 + fw={600} 97 + > 98 + {c.name} 99 + </Menu.Item> 100 + ))} 101 + </ScrollArea.Autosize> 102 + </Menu.Dropdown> 103 + </Menu> 104 + )} 57 105 </Fragment> 58 106 )} 59 107 <Text fz={'sm'} fw={600} c={'gray'} span display={'block'}> ··· 64 112 }; 65 113 66 114 return ( 67 - <Paper bg={'gray.1'} radius={'lg'}> 115 + <Card p={0} bg={colorScheme === 'dark' ? 'dark.4' : 'gray.1'} radius={'lg'}> 68 116 <Stack gap={'xs'}> 69 117 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 70 118 <Avatar ··· 76 124 {renderActivityText()} 77 125 </Group> 78 126 </Stack> 79 - </Paper> 127 + </Card> 80 128 ); 81 129 }
+12 -3
src/webapp/features/feeds/components/feedItem/Skeleton.FeedItem.tsx
··· 1 + 'use client'; 2 + 1 3 import UrlCardSkeleton from '@/features/cards/components/urlCard/Skeleton.UrlCard'; 2 - import { Avatar, Group, Paper, Skeleton, Stack } from '@mantine/core'; 4 + import { Avatar, Card, Group, Paper, Skeleton, Stack } from '@mantine/core'; 5 + import { useColorScheme } from '@mantine/hooks'; 3 6 4 7 export default function FeedItemSkeleton() { 8 + const colorScheme = useColorScheme(); 9 + 5 10 return ( 6 11 <Stack gap={'xs'} align="stretch"> 7 12 {/* Feed activity status*/} 8 - <Paper bg={'gray.1'} radius={'lg'}> 13 + <Card 14 + p={0} 15 + bg={colorScheme === 'dark' ? 'dark.4' : 'gray.1'} 16 + radius={'lg'} 17 + > 9 18 <Stack gap={'xs'} align="stretch" w={'100%'}> 10 19 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 11 20 <Avatar /> ··· 15 24 </Stack> 16 25 </Group> 17 26 </Stack> 18 - </Paper> 27 + </Card> 19 28 20 29 <UrlCardSkeleton /> 21 30 </Stack>
+1 -1
src/webapp/features/notes/components/editNoteDrawer/EditNoteDrawer.tsx
··· 68 68 </Drawer.Title> 69 69 </Drawer.Header> 70 70 71 - <Container size="sm"> 71 + <Container size="sm" p={0}> 72 72 <form onSubmit={handleUpdateNote}> 73 73 <Stack> 74 74 <Textarea
+2 -2
src/webapp/features/notes/components/noteCard/NoteCard.tsx
··· 15 15 const relativeCreateDate = time === 'just now' ? `${time}` : `${time} ago`; 16 16 17 17 return ( 18 - <Card p={'sm'} radius={'lg'} withBorder> 19 - <Stack> 18 + <Card p={'sm'} radius={'lg'} h={'100%'} withBorder> 19 + <Stack justify="space-between" h={'100%'}> 20 20 <Spoiler showLabel={'Read more'} hideLabel={'See less'} maxHeight={200}> 21 21 <Text fs={'italic'}>{props.note}</Text> 22 22 </Spoiler>
+1 -1
src/webapp/features/notes/components/noteCard/Skeleton.NoteCard.tsx
··· 2 2 3 3 export default function NoteCardSkeleton() { 4 4 return ( 5 - <Card p={'sm'} withBorder> 5 + <Card p={'sm'} radius={'lg'} withBorder> 6 6 <Stack gap={'xs'}> 7 7 {/* Note */} 8 8 <Stack gap={5}>
+72 -7
src/webapp/features/notes/components/noteCardModal/NoteCardModalContent.tsx
··· 14 14 } from '@mantine/core'; 15 15 import { UrlCard, User } from '@semble/types'; 16 16 import Link from 'next/link'; 17 - import { useState } from 'react'; 17 + import { Fragment, useState } from 'react'; 18 18 import useUpdateNote from '../../lib/mutations/useUpdateNote'; 19 19 import { notifications } from '@mantine/notifications'; 20 + import useRemoveCardFromLibrary from '@/features/cards/lib/mutations/useRemoveCardFromLibrary'; 20 21 21 22 interface Props { 23 + onClose: () => void; 22 24 note: UrlCard['note']; 23 25 cardContent: UrlCard['cardContent']; 24 26 cardAuthor?: User; ··· 30 32 const isMyCard = props.cardAuthor?.id === cardStatus.data.card?.author.id; 31 33 const [note, setNote] = useState(isMyCard ? props.note?.text : ''); 32 34 const [editMode, setEditMode] = useState(false); 35 + const [showDeleteWarning, setShowDeleteWarning] = useState(false); 33 36 37 + const removeNote = useRemoveCardFromLibrary(); 34 38 const updateNote = useUpdateNote(); 35 39 40 + const handleDeleteNote = () => { 41 + if (!isMyCard || !props.note) return; 42 + 43 + removeNote.mutate(props.note.id, { 44 + onError: () => { 45 + notifications.show({ 46 + message: 'Could not delete note.', 47 + position: 'top-center', 48 + }); 49 + }, 50 + onSettled: () => { 51 + props.onClose(); 52 + }, 53 + }); 54 + }; 55 + 36 56 const handleUpdateNote = () => { 37 - if (!props.note || !note) return; 57 + if (!props.note || !note) { 58 + props.onClose(); 59 + return; 60 + } 61 + 62 + if (props.note.text === note) { 63 + props.onClose(); 64 + return; 65 + } 38 66 39 67 updateNote.mutate( 40 68 { ··· 151 179 </Text> 152 180 )} 153 181 </Stack> 154 - {isMyCard && ( 182 + </Group> 183 + </Stack> 184 + </Card> 185 + {isMyCard && ( 186 + <Fragment> 187 + {showDeleteWarning ? ( 188 + <Group justify="space-between" gap={'xs'}> 189 + <Text>Delete note?</Text> 190 + <Group gap={'xs'}> 191 + <Button 192 + color="red" 193 + onClick={handleDeleteNote} 194 + loading={removeNote.isPending} 195 + > 196 + Delete 197 + </Button> 198 + <Button 199 + variant="light" 200 + color="gray" 201 + onClick={() => setShowDeleteWarning(false)} 202 + > 203 + Cancel 204 + </Button> 205 + </Group> 206 + </Group> 207 + ) : ( 208 + <Group gap={'xs'} grow> 155 209 <Button 156 210 variant="light" 157 211 color="gray" ··· 162 216 > 163 217 Edit note 164 218 </Button> 165 - )} 166 - </Group> 167 - </Stack> 168 - </Card> 219 + 220 + <Button 221 + variant="light" 222 + color="red" 223 + onClick={(e) => { 224 + e.stopPropagation(); 225 + setShowDeleteWarning(true); 226 + }} 227 + > 228 + Delete note 229 + </Button> 230 + </Group> 231 + )} 232 + </Fragment> 233 + )} 169 234 </Stack> 170 235 ); 171 236 }
+6 -1
src/webapp/features/notes/lib/mutations/useUpdateNote.tsx
··· 3 3 import { cardKeys } from '@/features/cards/lib/cardKeys'; 4 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 5 import { feedKeys } from '@/features/feeds/lib/feedKeys'; 6 + import { noteKeys } from '../noteKeys'; 6 7 7 8 export default function useUpdateNote() { 8 9 const queryClient = useQueryClient(); ··· 15 16 onSuccess: (data) => { 16 17 queryClient.invalidateQueries({ queryKey: cardKeys.card(data.cardId) }); 17 18 queryClient.invalidateQueries({ queryKey: cardKeys.infinite() }); 18 - queryClient.invalidateQueries({ queryKey: cardKeys.infinite() }); 19 + queryClient.invalidateQueries({ 20 + queryKey: cardKeys.infinite(data.cardId), 21 + }); 22 + queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 23 + queryClient.invalidateQueries({ queryKey: noteKeys.all() }); 19 24 queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 20 25 queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 21 26 },
+3 -3
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
··· 23 23 const profile = await getProfile(props.handle); 24 24 25 25 return ( 26 - <Container bg={'white'} p={0} size={'xl'}> 26 + <Container p={0} size={'xl'}> 27 27 <MinimalProfileHeaderContainer 28 28 avatarUrl={profile.avatarUrl} 29 29 name={profile.name} ··· 69 69 component="a" 70 70 href={`https://bsky.app/profile/${profile.handle}`} 71 71 target="_blank" 72 + variant="light" 72 73 radius={'xl'} 73 - bg="gray.2" 74 - c={'gray'} 74 + color={'gray'} 75 75 leftSection={<FaBluesky />} 76 76 > 77 77 {truncateText(profile.handle, 14)}
+1 -1
src/webapp/features/profile/components/profileHeader/Skeleton.ProfileHeader.tsx
··· 10 10 11 11 export default function ProfileHeaderSkeleton() { 12 12 return ( 13 - <Container bg={'white'} p={'xs'} size={'xl'}> 13 + <Container p={'xs'} size={'xl'}> 14 14 <Stack gap={'sm'}> 15 15 <Stack gap={'xl'}> 16 16 <Grid gutter={'md'} align={'center'} grow>
+58 -8
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'; ··· 23 31 const { data, error, isPending } = useMyProfile(); 24 32 const { logout } = useAuth(); 25 33 34 + const { colorScheme, setColorScheme } = useMantineColorScheme(); 35 + const computedColorScheme = useComputedColorScheme('light', { 36 + getInitialValueInEffect: true, 37 + }); 38 + 26 39 const handleLogout = async () => { 27 40 try { 28 41 await logout(); ··· 32 45 } 33 46 }; 34 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 + 35 59 if (isPending || !data) { 36 - return <Skeleton w={38} h={38} radius={'md'} ml={4} />; 60 + return <Skeleton w={38} h={38} radius="md" ml={4} />; 37 61 } 38 62 39 63 if (error) { ··· 46 70 <Menu.Target> 47 71 <Button 48 72 variant="subtle" 49 - color="gray" 50 - c={'dark'} 51 - fz={'md'} 52 - radius={'md'} 73 + color={computedColorScheme === 'dark' ? 'gray' : 'dark'} 74 + fz="md" 75 + radius="md" 53 76 size="lg" 54 77 px={3} 55 - fullWidth={true} 78 + fullWidth 56 79 justify="start" 57 80 leftSection={<Avatar src={data.avatarUrl} />} 58 81 > 59 82 {data.name} 60 83 </Button> 61 84 </Menu.Target> 85 + 62 86 <Menu.Dropdown> 63 87 <Menu.Item 64 88 component={Link} ··· 90 114 91 115 <Menu.Divider /> 92 116 117 + {/*<Menu.Item 118 + color="gray" 119 + leftSection={ 120 + colorScheme === 'auto' ? ( 121 + <MdAutoAwesome /> 122 + ) : computedColorScheme === 'dark' ? ( 123 + <MdDarkMode /> 124 + ) : ( 125 + <MdLightMode /> 126 + ) 127 + } 128 + closeMenuOnClick={false} 129 + onClick={handleThemeToggle} 130 + > 131 + Theme: {colorScheme} 132 + </Menu.Item>*/} 133 + 93 134 <Menu.Item 94 135 component="a" 95 136 href="https://cosmik.network/" 96 137 target="_blank" 97 138 > 98 - <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 + /> 99 149 </Menu.Item> 100 150 </Menu.Dropdown> 101 151 </Menu>
+18 -16
src/webapp/features/profile/components/profileTabs/ProfileTabs.tsx
··· 1 1 'use client'; 2 2 3 - import { Group, ScrollAreaAutosize, Tabs } from '@mantine/core'; 3 + import { Group, Paper, ScrollAreaAutosize, Tabs } from '@mantine/core'; 4 4 import TabItem from './TabItem'; 5 5 import { usePathname } from 'next/navigation'; 6 6 ··· 16 16 17 17 return ( 18 18 <Tabs value={currentTab}> 19 - <ScrollAreaAutosize type="scroll"> 20 - <Tabs.List> 21 - <Group wrap="nowrap"> 22 - <TabItem value="profile" href={basePath}> 23 - Profile 24 - </TabItem> 25 - <TabItem value="cards" href={`${basePath}/cards`}> 26 - Cards 27 - </TabItem> 28 - <TabItem value="collections" href={`${basePath}/collections`}> 29 - Collections 30 - </TabItem> 31 - </Group> 32 - </Tabs.List> 33 - </ScrollAreaAutosize> 19 + <Paper radius={0}> 20 + <ScrollAreaAutosize type="scroll"> 21 + <Tabs.List> 22 + <Group wrap="nowrap"> 23 + <TabItem value="profile" href={basePath}> 24 + Profile 25 + </TabItem> 26 + <TabItem value="cards" href={`${basePath}/cards`}> 27 + Cards 28 + </TabItem> 29 + <TabItem value="collections" href={`${basePath}/collections`}> 30 + Collections 31 + </TabItem> 32 + </Group> 33 + </Tabs.List> 34 + </ScrollAreaAutosize> 35 + </Paper> 34 36 </Tabs> 35 37 ); 36 38 }
+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 }
+10 -2
src/webapp/features/semble/components/SembleHeader/SembleHeader.tsx
··· 9 9 Tooltip, 10 10 Spoiler, 11 11 Card, 12 + Button, 12 13 } from '@mantine/core'; 13 14 import Link from 'next/link'; 14 15 import { getUrlMetadata } from '@/features/cards/lib/dal'; 15 16 import { getDomain } from '@/lib/utils/link'; 16 17 import UrlAddedBySummary from '../urlAddedBySummary/UrlAddedBySummary'; 17 - // import SembleActions from '../sembleActions/SembleActions'; 18 + import SembleActions from '../sembleActions/SembleActions'; 19 + import { verifySessionOnServer } from '@/lib/auth/dal.server'; 20 + import GuestSembleActions from '../sembleActions/GusetSembleActions'; 18 21 19 22 interface Props { 20 23 url: string; ··· 22 25 23 26 export default async function SembleHeader(props: Props) { 24 27 const { metadata } = await getUrlMetadata(props.url); 28 + const session = await verifySessionOnServer(); 25 29 26 30 return ( 27 31 <Stack gap={'xl'}> ··· 74 78 /> 75 79 </Card> 76 80 )} 77 - {/*<SembleActions url={props.url} />*/} 81 + {session ? ( 82 + <SembleActions url={props.url} /> 83 + ) : ( 84 + <GuestSembleActions url={props.url} /> 85 + )} 78 86 </Stack> 79 87 </GridCol> 80 88 </Grid>
+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
··· 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 + }
+51 -3
src/webapp/features/semble/components/sembleActions/SembleActions.tsx
··· 1 1 'use client'; 2 2 3 + import AddCardToModal from '@/features/cards/components/addCardToModal/AddCardToModal'; 3 4 import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; 4 - import { Button, Group } from '@mantine/core'; 5 - import { Fragment } from 'react'; 5 + import { ActionIcon, Button, CopyButton, Group, Tooltip } from '@mantine/core'; 6 + import { notifications } from '@mantine/notifications'; 7 + import { Fragment, useState } from 'react'; 6 8 import { FiPlus } from 'react-icons/fi'; 7 9 import { IoMdCheckmark } from 'react-icons/io'; 10 + import { MdIosShare } from 'react-icons/md'; 8 11 9 12 interface Props { 10 13 url: string; ··· 13 16 export default function SembleActions(props: Props) { 14 17 const cardStatus = useGetCardFromMyLibrary({ url: props.url }); 15 18 const isInYourLibrary = cardStatus.data.card?.urlInLibrary; 19 + const [showAddToModal, setShowAddToModal] = useState(false); 20 + 21 + const shareLink = 22 + typeof window !== 'undefined' 23 + ? `${window.location.origin}/url?id=${props.url}` 24 + : ''; 16 25 17 26 if (cardStatus.error) { 18 27 return null; ··· 20 29 21 30 return ( 22 31 <Fragment> 23 - <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> 24 61 <Button 25 62 variant={isInYourLibrary ? 'default' : 'filled'} 26 63 size="md" 27 64 leftSection={ 28 65 isInYourLibrary ? <IoMdCheckmark size={18} /> : <FiPlus size={18} /> 29 66 } 67 + onClick={() => setShowAddToModal(true)} 30 68 > 31 69 {isInYourLibrary ? 'In library' : 'Add to library'} 32 70 </Button> 33 71 </Group> 72 + 73 + <AddCardToModal 74 + isOpen={showAddToModal} 75 + onClose={() => setShowAddToModal(false)} 76 + url={props.url} 77 + cardId={cardStatus.data.card?.id} 78 + note={cardStatus.data.card?.note?.text} 79 + urlLibraryCount={cardStatus.data.card?.urlLibraryCount} 80 + isInYourLibrary={cardStatus.data.card?.urlInLibrary} 81 + /> 34 82 </Fragment> 35 83 ); 36 84 }
+1 -1
src/webapp/features/semble/components/sembleTabs/TabItem.tsx
··· 8 8 9 9 export default function TabItem(props: Props) { 10 10 return ( 11 - <TabsTab c={'dark'} value={props.value} className={classes.tab} fw={600}> 11 + <TabsTab value={props.value} className={classes.tab} fw={600}> 12 12 {props.children} 13 13 </TabsTab> 14 14 );
+1 -1
src/webapp/features/semble/components/urlAddedBySummary/Skeleton.UrlAddedBySummary.tsx
··· 1 1 import { Avatar, Group, Skeleton } from '@mantine/core'; 2 2 3 - export default async function UrlAddedBySummarySkeleton() { 3 + export default function UrlAddedBySummarySkeleton() { 4 4 return ( 5 5 <Group gap={'xs'}> 6 6 <Avatar src={null} />
+8 -10
src/webapp/features/semble/containers/sembleCollectionsContainer/Skeleton.SembleCollectionsContainer.tsx
··· 1 - import { Container, SimpleGrid, Stack } from '@mantine/core'; 1 + import { SimpleGrid, Stack } from '@mantine/core'; 2 2 import CollectionCardSkeleton from '@/features/collections/components/collectionCard/Skeleton.CollectionCard'; 3 3 4 4 export default function SembleCollectionsContainerSkeleton() { 5 5 return ( 6 - <Container p="xs" size="xl"> 7 - <Stack> 8 - <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md"> 9 - {Array.from({ length: 4 }).map((_, i) => ( 10 - <CollectionCardSkeleton key={i} /> 11 - ))} 12 - </SimpleGrid> 13 - </Stack> 14 - </Container> 6 + <Stack> 7 + <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md"> 8 + {Array.from({ length: 4 }).map((_, i) => ( 9 + <CollectionCardSkeleton key={i} /> 10 + ))} 11 + </SimpleGrid> 12 + </Stack> 15 13 ); 16 14 }
+3 -24
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; ··· 12 12 export default async function SembleContainer(props: Props) { 13 13 return ( 14 14 <Container p={0} fluid> 15 - <Box style={{ position: 'relative', width: '100%' }}> 16 - <Image 17 - src={BG.src} 18 - alt="bg" 19 - fit="cover" 20 - w="100%" 21 - h={{ base: 100, md: 120 }} 22 - /> 23 - 24 - {/* White gradient overlay */} 25 - <Box 26 - style={{ 27 - position: 'absolute', 28 - bottom: 0, 29 - left: 0, 30 - width: '100%', 31 - height: '60%', // fade height 32 - background: 'linear-gradient(to top, white, transparent)', 33 - pointerEvents: 'none', 34 - }} 35 - /> 36 - </Box> 15 + <SembleHeaderBackground /> 37 16 <Container px={'xs'} pb={'xs'} size={'xl'}> 38 17 <Stack gap={'xl'}> 39 18 <Suspense fallback={<SembleHeaderSkeleton />}>
+36
src/webapp/features/semble/containers/sembleContainer/SembleHeaderBackground.tsx
··· 1 + 'use client'; 2 + 3 + import { useColorScheme } from '@mantine/hooks'; 4 + import BG from '@/assets/semble-header-bg.webp'; 5 + import DarkBG from '@/assets/semble-header-bg-dark.webp'; 6 + import { Box, Image } from '@mantine/core'; 7 + 8 + export default function SembleHeaderBackground() { 9 + const colorScheme = useColorScheme(); 10 + 11 + return ( 12 + <Box style={{ position: 'relative', width: '100%' }}> 13 + <Image 14 + src={colorScheme === 'dark' ? DarkBG.src : BG.src} 15 + alt="bg" 16 + fit="cover" 17 + w="100%" 18 + h={80} 19 + /> 20 + 21 + {/* White gradient overlay */} 22 + <Box 23 + style={{ 24 + position: 'absolute', 25 + bottom: 0, 26 + left: 0, 27 + width: '100%', 28 + height: '60%', // fade height 29 + background: 30 + 'linear-gradient(to top, var(--mantine-color-body), transparent)', 31 + pointerEvents: 'none', 32 + }} 33 + /> 34 + </Box> 35 + ); 36 + }
+16
src/webapp/features/semble/containers/sembleContainer/Skeleton.SembleContainer.tsx
··· 1 + import { Container, Stack } from '@mantine/core'; 2 + import SembleHeaderSkeleton from '../../components/SembleHeader/Skeleton.SembleHeader'; 3 + import SembleHeaderBackground from './SembleHeaderBackground'; 4 + 5 + export default function SembleContainerSkeleton() { 6 + return ( 7 + <Container p={0} fluid> 8 + <SembleHeaderBackground /> 9 + <Container px={'xs'} pb={'xs'} size={'xl'}> 10 + <Stack gap={'xl'}> 11 + <SembleHeaderSkeleton /> 12 + </Stack> 13 + </Container> 14 + </Container> 15 + ); 16 + }
+1 -1
src/webapp/providers/mantine.tsx
··· 12 12 13 13 export default function MantineProvider(props: Props) { 14 14 return ( 15 - <BaseProvider theme={theme}> 15 + <BaseProvider theme={theme} forceColorScheme="light"> 16 16 <Notifications position="bottom-right" /> 17 17 {props.children} 18 18 </BaseProvider>
+4 -1
src/webapp/styles/theme.tsx
··· 11 11 Spoiler, 12 12 TabsTab, 13 13 Tooltip, 14 - Title, 15 14 Text, 16 15 } from '@mantine/core'; 17 16 18 17 export const theme = createTheme({ 18 + primaryShade: { 19 + light: 6, 20 + dark: 6, 21 + }, 19 22 primaryColor: 'tangerine', 20 23 colors: { 21 24 tangerine: [