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

+721 -195
+2 -2
src/webapp/.storybook/preview.tsx
··· 1 import '@mantine/core/styles.css'; 2 3 - import React, { useEffect } from 'react'; 4 - import { MantineProvider, useMantineColorScheme } from '@mantine/core'; 5 import { theme } from '../styles/theme'; 6 import { Hanken_Grotesk } from 'next/font/google'; 7
··· 1 import '@mantine/core/styles.css'; 2 3 + import React from 'react'; 4 + import { MantineProvider } from '@mantine/core'; 5 import { theme } from '../styles/theme'; 6 import { Hanken_Grotesk } from 'next/font/google'; 7
+2 -2
src/webapp/app/(auth)/login/page.tsx
··· 71 <Popover withArrow shadow="sm"> 72 <PopoverTarget> 73 <Button 74 - variant="white" 75 size="md" 76 fw={500} 77 fs={'italic'} 78 - c={'stone'} 79 rightSection={<IoMdHelpCircleOutline size={22} />} 80 > 81 How your Cosmik Network account works
··· 71 <Popover withArrow shadow="sm"> 72 <PopoverTarget> 73 <Button 74 + variant="transparent" 75 size="md" 76 fw={500} 77 fs={'italic'} 78 + c={'gray'} 79 rightSection={<IoMdHelpCircleOutline size={22} />} 80 > 81 How your Cosmik Network account works
+11 -2
src/webapp/app/(dashboard)/error.tsx
··· 13 } from '@mantine/core'; 14 import SembleLogo from '@/assets/semble-logo.svg'; 15 import BG from '@/assets/semble-bg.webp'; 16 import Link from 'next/link'; 17 import { BiRightArrowAlt } from 'react-icons/bi'; 18 19 export default function Error() { 20 return ( 21 <BackgroundImage 22 - src={BG.src} 23 h={'100svh'} 24 pos={'fixed'} 25 top={0} ··· 44 <Text fz={'h1'} fw={600} ta={'center'}> 45 A social knowledge network for researchers 46 </Text> 47 - <Text fz={'h3'} fw={600} c={'#1F6144'} ta={'center'}> 48 Follow your peers’ research trails. Surface and discover new 49 connections. Built on ATProto so you own your data. 50 </Text>
··· 13 } from '@mantine/core'; 14 import SembleLogo from '@/assets/semble-logo.svg'; 15 import BG from '@/assets/semble-bg.webp'; 16 + import DarkBG from '@/assets/semble-bg-dark.png'; 17 import Link from 'next/link'; 18 import { BiRightArrowAlt } from 'react-icons/bi'; 19 + import { useColorScheme } from '@mantine/hooks'; 20 21 export default function Error() { 22 + const colorScheme = useColorScheme(); 23 + 24 return ( 25 <BackgroundImage 26 + src={colorScheme === 'dark' ? DarkBG.src : BG.src} 27 h={'100svh'} 28 pos={'fixed'} 29 top={0} ··· 48 <Text fz={'h1'} fw={600} ta={'center'}> 49 A social knowledge network for researchers 50 </Text> 51 + <Text 52 + fz={'h3'} 53 + fw={600} 54 + c={colorScheme === 'dark' ? '#1e4dd9' : '#1F6144'} 55 + ta={'center'} 56 + > 57 Follow your peers’ research trails. Surface and discover new 58 connections. Built on ATProto so you own your data. 59 </Text>
+1 -1
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx
··· 42 zIndex: 1, 43 }} 44 > 45 - <Container bg={'white'} px={'xs'} mt={'md'} size={'xl'}> 46 <ProfileTabs handle={handle} /> 47 </Container> 48 </Box>
··· 42 zIndex: 1, 43 }} 44 > 45 + <Container px={'xs'} mt={'md'} size={'xl'}> 46 <ProfileTabs handle={handle} /> 47 </Container> 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 {...mantineHtmlProps} 27 > 28 <head> 29 - <ColorSchemeScript /> 30 </head> 31 <body className={GlobalStyles.main}> 32 <Providers>{children}</Providers>
··· 26 {...mantineHtmlProps} 27 > 28 <head> 29 + <ColorSchemeScript forceColorScheme="light" /> 30 </head> 31 <body className={GlobalStyles.main}> 32 <Providers>{children}</Providers>
+23 -3
src/webapp/app/page.tsx
··· 1 import { 2 ActionIcon, 3 SimpleGrid, ··· 18 import { BiRightArrowAlt } from 'react-icons/bi'; 19 import { RiArrowRightUpLine } from 'react-icons/ri'; 20 import BG from '@/assets/semble-bg.webp'; 21 import CosmikLogo from '@/assets/cosmik-logo-full.svg'; 22 import CurateIcon from '@/assets/icons/curate-icon.svg'; 23 import CommunityIcon from '@/assets/icons/community-icon.svg'; 24 import DBIcon from '@/assets/icons/db-icon.svg'; ··· 26 import TangledIcon from '@/assets/icons/tangled-icon.svg'; 27 import SembleLogo from '@/assets/semble-logo.svg'; 28 import Link from 'next/link'; 29 30 export default function Home() { 31 return ( 32 - <BackgroundImage src={BG.src} h={'100svh'}> 33 <script async src="https://tally.so/widgets/embed.js" /> 34 <Container size={'xl'} p={'md'} my={'auto'}> 35 <Group justify="space-between"> ··· 56 <Title order={1} fw={600} fz={'3rem'} ta={'center'}> 57 A social knowledge network for researchers 58 </Title> 59 - <Title order={2} fw={600} fz={'xl'} c={'#1F6144'} ta={'center'}> 60 Follow your peers’ research trails. Surface and discover new 61 connections. Built on ATProto so you own your data. 62 </Title> ··· 223 style={{ verticalAlign: 'middle' }} 224 > 225 <Image 226 - src={CosmikLogo.src} 227 alt="Cosmik logo" 228 w={92} 229 h={28.4}
··· 1 + 'use client'; 2 + 3 import { 4 ActionIcon, 5 SimpleGrid, ··· 20 import { BiRightArrowAlt } from 'react-icons/bi'; 21 import { RiArrowRightUpLine } from 'react-icons/ri'; 22 import BG from '@/assets/semble-bg.webp'; 23 + import DarkBG from '@/assets/semble-bg-dark.png'; 24 import CosmikLogo from '@/assets/cosmik-logo-full.svg'; 25 + import CosmikLogoWhite from '@/assets/cosmik-logo-full-white.svg'; 26 import CurateIcon from '@/assets/icons/curate-icon.svg'; 27 import CommunityIcon from '@/assets/icons/community-icon.svg'; 28 import DBIcon from '@/assets/icons/db-icon.svg'; ··· 30 import TangledIcon from '@/assets/icons/tangled-icon.svg'; 31 import SembleLogo from '@/assets/semble-logo.svg'; 32 import Link from 'next/link'; 33 + import { useColorScheme } from '@mantine/hooks'; 34 35 export default function Home() { 36 + const colorScheme = useColorScheme(); 37 + 38 return ( 39 + <BackgroundImage 40 + src={colorScheme === 'dark' ? DarkBG.src : BG.src} 41 + h={'100svh'} 42 + > 43 <script async src="https://tally.so/widgets/embed.js" /> 44 <Container size={'xl'} p={'md'} my={'auto'}> 45 <Group justify="space-between"> ··· 66 <Title order={1} fw={600} fz={'3rem'} ta={'center'}> 67 A social knowledge network for researchers 68 </Title> 69 + <Title 70 + order={2} 71 + fw={600} 72 + fz={'xl'} 73 + c={colorScheme === 'dark' ? '#1e4dd9' : '#1F6144'} 74 + ta={'center'} 75 + > 76 Follow your peers’ research trails. Surface and discover new 77 connections. Built on ATProto so you own your data. 78 </Title> ··· 239 style={{ verticalAlign: 'middle' }} 240 > 241 <Image 242 + src={ 243 + colorScheme === 'dark' 244 + ? CosmikLogoWhite.src 245 + : CosmikLogo.src 246 + } 247 alt="Cosmik logo" 248 w={92} 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 onClose={handleClose} 121 title="Add to Collections" 122 centered 123 - size="md" 124 > 125 <Stack p="sm"> 126 {loading ? (
··· 120 onClose={handleClose} 121 title="Add to Collections" 122 centered 123 > 124 <Stack p="sm"> 125 {loading ? (
+1 -1
src/webapp/components/navigation/appLayout/AppLayout.tsx
··· 14 15 export default function AppLayout(props: Props) { 16 const { mobileOpened, desktopOpened } = useNavbarContext(); 17 - const isMobile = useMediaQuery('(max-width: 48em)'); // "sm" breakpoint 18 const pathname = usePathname(); 19 20 const ROUTES_WITH_ASIDE = ['/url'];
··· 14 15 export default function AppLayout(props: Props) { 16 const { mobileOpened, desktopOpened } = useNavbarContext(); 17 + const isMobile = useMediaQuery('(max-width: 48em)', true); // "sm" breakpoint 18 const pathname = usePathname(); 19 20 const ROUTES_WITH_ASIDE = ['/url'];
-1
src/webapp/components/navigation/bottomBar/BottomBar.tsx
··· 2 import { FaRegNoteSticky } from 'react-icons/fa6'; 3 import { LuLibrary } from 'react-icons/lu'; 4 import { MdOutlineEmojiNature } from 'react-icons/md'; 5 - import NavbarToggle from '../NavbarToggle'; 6 import BottomBarItem from '../bottomBarItem/BottomBarItem'; 7 import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 8
··· 2 import { FaRegNoteSticky } from 'react-icons/fa6'; 3 import { LuLibrary } from 'react-icons/lu'; 4 import { MdOutlineEmojiNature } from 'react-icons/md'; 5 import BottomBarItem from '../bottomBarItem/BottomBarItem'; 6 import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 7
+3 -2
src/webapp/components/navigation/bottomBarItem/BottomBarItem.tsx
··· 3 import { usePathname } from 'next/navigation'; 4 import Link from 'next/link'; 5 import { ReactElement, isValidElement } from 'react'; 6 7 interface Props { 8 href: string; ··· 10 } 11 12 export default function BottomBarItem(props: Props) { 13 const pathname = usePathname(); 14 const isActive = pathname === props.href; 15 ··· 28 href={props.href} 29 variant={isActive ? 'light' : 'transparent'} 30 size={'lg'} 31 - bg={isActive ? 'gray.1' : 'transparent'} 32 - color={isActive ? 'dark' : 'gray'} 33 > 34 {renderIcon()} 35 </ActionIcon>
··· 3 import { usePathname } from 'next/navigation'; 4 import Link from 'next/link'; 5 import { ReactElement, isValidElement } from 'react'; 6 + import { useColorScheme } from '@mantine/hooks'; 7 8 interface Props { 9 href: string; ··· 11 } 12 13 export default function BottomBarItem(props: Props) { 14 + const colorScheme = useColorScheme(); 15 const pathname = usePathname(); 16 const isActive = pathname === props.href; 17 ··· 30 href={props.href} 31 variant={isActive ? 'light' : 'transparent'} 32 size={'lg'} 33 + color="gray" 34 > 35 {renderIcon()} 36 </ActionIcon>
+3
src/webapp/components/navigation/guestAppLayout/GuestAppLayout.tsx
··· 4 import { useNavbarContext } from '@/providers/navbar'; 5 import { usePathname } from 'next/navigation'; 6 import GuestNavbar from '../guestNavbar/GuestNavbar'; 7 8 interface Props { 9 children: React.ReactNode; ··· 36 <GuestNavbar /> 37 38 <AppShell.Main>{props.children}</AppShell.Main> 39 </AppShell> 40 ); 41 }
··· 4 import { useNavbarContext } from '@/providers/navbar'; 5 import { usePathname } from 'next/navigation'; 6 import GuestNavbar from '../guestNavbar/GuestNavbar'; 7 + import GuestBottomBar from '../guestBottomBar/GuestBottomBar'; 8 9 interface Props { 10 children: React.ReactNode; ··· 37 <GuestNavbar /> 38 39 <AppShell.Main>{props.children}</AppShell.Main> 40 + 41 + <GuestBottomBar /> 42 </AppShell> 43 ); 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'; 2 import NavbarToggle from '../NavbarToggle'; 3 import { ReactElement } from 'react'; 4 ··· 8 9 export default function Header(props: Props) { 10 return ( 11 - <Box pos={'sticky'} top={0} bg={'white'} style={{ zIndex: 1 }}> 12 <Group gap={'xs'} p={'xs'} justify="space-between"> 13 {props.children} 14 <Box ml={'auto'}> ··· 16 </Box> 17 </Group> 18 <Divider /> 19 - </Box> 20 ); 21 }
··· 1 + import { Box, Divider, Group, Paper } from '@mantine/core'; 2 import NavbarToggle from '../NavbarToggle'; 3 import { ReactElement } from 'react'; 4 ··· 8 9 export default function Header(props: Props) { 10 return ( 11 + <Paper pos={'sticky'} top={0} style={{ zIndex: 1 }}> 12 <Group gap={'xs'} p={'xs'} justify="space-between"> 13 {props.children} 14 <Box ml={'auto'}> ··· 16 </Box> 17 </Group> 18 <Divider /> 19 + </Paper> 20 ); 21 }
+3 -1
src/webapp/components/navigation/navbar/Navbar.tsx
··· 36 <Anchor component={Link} href={'/home'}> 37 <Stack align="center" gap={6}> 38 <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} /> 39 - <Badge size="xs">Alpha</Badge> 40 </Stack> 41 </Anchor> 42 <Box hiddenFrom="xs">
··· 36 <Anchor component={Link} href={'/home'}> 37 <Stack align="center" gap={6}> 38 <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} /> 39 + <Badge size="xs" style={{ cursor: 'pointer' }}> 40 + Alpha 41 + </Badge> 42 </Stack> 43 </Anchor> 44 <Box hiddenFrom="xs">
+2 -2
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
··· 89 New Card 90 </Drawer.Title> 91 </Drawer.Header> 92 - <Container size={'sm'}> 93 <form onSubmit={handleAddCard}> 94 <Stack gap={'xl'}> 95 <Stack> ··· 152 Add to collections 153 </Drawer.Title> 154 </Drawer.Header> 155 - <Container size={'xs'}> 156 <Suspense fallback={<CollectionSelectorSkeleton />}> 157 <CollectionSelector 158 isOpen={collectionSelectorOpened}
··· 89 New Card 90 </Drawer.Title> 91 </Drawer.Header> 92 + <Container size={'sm'} p={0}> 93 <form onSubmit={handleAddCard}> 94 <Stack gap={'xl'}> 95 <Stack> ··· 152 Add to collections 153 </Drawer.Title> 154 </Drawer.Header> 155 + <Container size={'xs'} p={0}> 156 <Suspense fallback={<CollectionSelectorSkeleton />}> 157 <CollectionSelector 158 isOpen={collectionSelectorOpened}
+39 -17
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 1 - import type { UrlCard } from '@/api-client'; 2 import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 3 import { Modal, Stack, Text } from '@mantine/core'; 4 import { Suspense } from 'react'; 5 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 6 - import AddCardToModalContent from './AddCardToModalContent'; // new file or inline 7 8 interface Props { 9 isOpen: boolean; 10 onClose: () => void; 11 - cardContent: UrlCard['cardContent']; 12 - urlLibraryCount: number; 13 - cardId: string; 14 note?: string; 15 - isInYourLibrary: boolean; 16 } 17 18 export default function AddCardToModal(props: Props) { 19 return ( 20 <Modal 21 - opened={props.isOpen} 22 - onClose={props.onClose} 23 title={ 24 <Stack gap={0}> 25 - <Text fw={600}>Add or update card</Text> 26 <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`} 34 </Text> 35 </Stack> 36 } ··· 39 onClick={(e) => e.stopPropagation()} 40 > 41 <Suspense fallback={<CollectionSelectorSkeleton />}> 42 - <AddCardToModalContent {...props} /> 43 </Suspense> 44 </Modal> 45 );
··· 1 import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 2 import { Modal, Stack, Text } from '@mantine/core'; 3 import { Suspense } from 'react'; 4 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 5 + import AddCardToModalContent from './AddCardToModalContent'; 6 7 interface Props { 8 isOpen: boolean; 9 onClose: () => void; 10 + url: string; 11 + cardId?: string; 12 note?: string; 13 + urlLibraryCount?: number; 14 + isInYourLibrary?: boolean; 15 } 16 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 + 42 return ( 43 <Modal 44 + opened={isOpen} 45 + onClose={onClose} 46 title={ 47 <Stack gap={0}> 48 + <Text fw={600}>Add or update {props.cardId ? 'card' : 'link'}</Text> 49 <Text c="gray" fw={500}> 50 + {subtitle} 51 </Text> 52 </Stack> 53 } ··· 56 onClick={(e) => e.stopPropagation()} 57 > 58 <Suspense fallback={<CollectionSelectorSkeleton />}> 59 + <AddCardToModalContent 60 + onClose={onClose} 61 + url={url} 62 + cardId={cardId} 63 + note={note} 64 + /> 65 </Suspense> 66 </Modal> 67 );
+14 -8
src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx
··· 11 import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; 12 import useUpdateCardAssociations from '@/features/cards/lib/mutations/useUpdateCardAssociations'; 13 import useAddCard from '@/features/cards/lib/mutations/useAddCard'; 14 15 interface SelectableCollectionItem { 16 id: string; ··· 20 21 interface Props { 22 onClose: () => void; 23 - cardContent: UrlCard['cardContent']; 24 - urlLibraryCount: number; 25 - cardId: string; 26 note?: string; 27 - isInYourLibrary: boolean; 28 } 29 30 export default function AddCardToModalContent(props: Props) { 31 - const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 32 - const isMyCard = props.cardId === cardStatus.data.card?.id; 33 const [note, setNote] = useState(isMyCard ? props.note : ''); 34 const { data, error } = useMyCollections(); 35 ··· 79 if (!cardStatus.data.card) { 80 addCard.mutate( 81 { 82 - url: props.cardContent.url, 83 note: trimmedNote, 84 collectionIds: selectedCollections.map((c) => c.id), 85 }, ··· 124 return ( 125 <Stack justify="space-between"> 126 <CardToBeAddedPreview 127 - cardContent={props.cardContent} 128 note={isMyCard ? note : cardStatus.data.card?.note?.text} 129 onUpdateNote={setNote} 130 /> 131 132 <CollectionSelector
··· 11 import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; 12 import useUpdateCardAssociations from '@/features/cards/lib/mutations/useUpdateCardAssociations'; 13 import useAddCard from '@/features/cards/lib/mutations/useAddCard'; 14 + import useUrlMetadata from '../../lib/queries/useUrlMetadata'; 15 16 interface SelectableCollectionItem { 17 id: string; ··· 21 22 interface Props { 23 onClose: () => void; 24 + url: string; 25 + cardId?: string; 26 note?: string; 27 } 28 29 export default function AddCardToModalContent(props: Props) { 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; 35 const [note, setNote] = useState(isMyCard ? props.note : ''); 36 const { data, error } = useMyCollections(); 37 ··· 81 if (!cardStatus.data.card) { 82 addCard.mutate( 83 { 84 + url: props.url, 85 note: trimmedNote, 86 collectionIds: selectedCollections.map((c) => c.id), 87 }, ··· 126 return ( 127 <Stack justify="space-between"> 128 <CardToBeAddedPreview 129 + url={props.url} 130 + thumbnailUrl={metadata.imageUrl} 131 + title={metadata.title} 132 note={isMyCard ? note : cardStatus.data.card?.note?.text} 133 + noteId={cardStatus.data.card?.note?.id} 134 onUpdateNote={setNote} 135 + onClose={props.onClose} 136 /> 137 138 <CollectionSelector
+99 -37
src/webapp/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
··· 12 } from '@mantine/core'; 13 import Link from 'next/link'; 14 import { Dispatch, SetStateAction, useState } from 'react'; 15 - import { UrlCard } from '@/api-client'; 16 import { getDomain } from '@/lib/utils/link'; 17 18 interface Props { 19 - cardContent: UrlCard['cardContent']; 20 note?: string; 21 onUpdateNote: Dispatch<SetStateAction<string | undefined>>; 22 } 23 24 export default function CardToBeAddedPreview(props: Props) { 25 const [noteMode, setNoteMode] = useState(false); 26 const [note, setNote] = useState(props.note); 27 - const domain = getDomain(props.cardContent.url); 28 29 if (noteMode) { 30 return ( ··· 74 } 75 76 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> 108 )} 109 - </Stack> 110 <Button 111 variant="light" 112 color="gray" ··· 117 > 118 {note ? 'Edit note' : 'Add note'} 119 </Button> 120 </Group> 121 - </Stack> 122 - </Card> 123 ); 124 }
··· 12 } from '@mantine/core'; 13 import Link from 'next/link'; 14 import { Dispatch, SetStateAction, useState } from 'react'; 15 import { getDomain } from '@/lib/utils/link'; 16 + import useRemoveCardFromLibrary from '../../lib/mutations/useRemoveCardFromLibrary'; 17 + import { notifications } from '@mantine/notifications'; 18 19 interface Props { 20 + url: string; 21 + thumbnailUrl?: string; 22 + title?: string; 23 note?: string; 24 + noteId?: string; 25 onUpdateNote: Dispatch<SetStateAction<string | undefined>>; 26 + onClose: () => void; 27 } 28 29 export default function CardToBeAddedPreview(props: Props) { 30 + const [showDeleteWarning, setShowDeleteWarning] = useState(false); 31 const [noteMode, setNoteMode] = useState(false); 32 const [note, setNote] = useState(props.note); 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 + }; 52 53 if (noteMode) { 54 return ( ··· 98 } 99 100 return ( 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> 115 )} 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'}> 160 <Button 161 variant="light" 162 color="gray" ··· 167 > 168 {note ? 'Edit note' : 'Add note'} 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 + )} 182 </Group> 183 + )} 184 + </Stack> 185 ); 186 }
+1 -2
src/webapp/features/cards/components/urlCardActions/UrlCardActions.tsx
··· 32 const isAuthor = props.authorHandle 33 ? user?.handle === props.authorHandle 34 : true; 35 - const [showEditNoteModal, setShowEditNoteModal] = useState(false); 36 const [showNoteModal, setShowNoteModal] = useState(false); 37 const [showRemoveFromCollectionModal, setShowRemoveFromCollectionModal] = 38 useState(false); ··· 119 <AddCardToModal 120 isOpen={showAddToModal} 121 onClose={() => setShowAddToModal(false)} 122 - cardContent={props.cardContent} 123 cardId={props.id} 124 note={props.note?.text} 125 urlLibraryCount={props.urlLibraryCount}
··· 32 const isAuthor = props.authorHandle 33 ? user?.handle === props.authorHandle 34 : true; 35 const [showNoteModal, setShowNoteModal] = useState(false); 36 const [showRemoveFromCollectionModal, setShowRemoveFromCollectionModal] = 37 useState(false); ··· 118 <AddCardToModal 119 isOpen={showAddToModal} 120 onClose={() => setShowAddToModal(false)} 121 + url={props.cardContent.url} 122 cardId={props.id} 123 note={props.note?.text} 124 urlLibraryCount={props.urlLibraryCount}
+6
src/webapp/features/cards/lib/mutations/useAddCard.tsx
··· 2 import { addUrlToLibrary } from '../dal'; 3 import { cardKeys } from '../cardKeys'; 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 6 export default function useAddCard() { 7 const queryClient = useQueryClient(); ··· 24 onSuccess: (_data, variables) => { 25 queryClient.invalidateQueries({ queryKey: cardKeys.mine() }); 26 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 27 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 28 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 29 queryClient.invalidateQueries({ 30 queryKey: collectionKeys.bySembleUrl(variables.url), 31 }); ··· 34 variables.collectionIds?.forEach((id) => { 35 queryClient.invalidateQueries({ 36 queryKey: collectionKeys.collection(id), 37 }); 38 }); 39 },
··· 2 import { addUrlToLibrary } from '../dal'; 3 import { cardKeys } from '../cardKeys'; 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 + import { feedKeys } from '@/features/feeds/lib/feedKeys'; 6 7 export default function useAddCard() { 8 const queryClient = useQueryClient(); ··· 25 onSuccess: (_data, variables) => { 26 queryClient.invalidateQueries({ queryKey: cardKeys.mine() }); 27 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 28 + queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 29 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 30 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 31 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 32 queryClient.invalidateQueries({ 33 queryKey: collectionKeys.bySembleUrl(variables.url), 34 }); ··· 37 variables.collectionIds?.forEach((id) => { 38 queryClient.invalidateQueries({ 39 queryKey: collectionKeys.collection(id), 40 + }); 41 + queryClient.invalidateQueries({ 42 + queryKey: collectionKeys.infinite(id), 43 }); 44 }); 45 },
+4
src/webapp/features/cards/lib/mutations/useRemoveCardFromCollections.tsx
··· 19 onSuccess: (_data, variables) => { 20 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 21 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 22 23 variables.collectionIds.forEach((id) => { 24 queryClient.invalidateQueries({ 25 queryKey: collectionKeys.collection(id), 26 }); 27 }); 28 },
··· 19 onSuccess: (_data, variables) => { 20 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 21 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 22 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 23 24 variables.collectionIds.forEach((id) => { 25 queryClient.invalidateQueries({ 26 queryKey: collectionKeys.collection(id), 27 + }); 28 + queryClient.invalidateQueries({ 29 + queryKey: collectionKeys.infinite(id), 30 }); 31 }); 32 },
+4
src/webapp/features/cards/lib/mutations/useRemoveCardFromLibrary.tsx
··· 2 import { removeCardFromLibrary } from '../dal'; 3 import { cardKeys } from '../cardKeys'; 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 6 export default function useRemoveCardFromLibrary() { 7 const queryClient = useQueryClient(); ··· 13 14 onSuccess: () => { 15 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 16 queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 17 }, 18 });
··· 2 import { removeCardFromLibrary } from '../dal'; 3 import { cardKeys } from '../cardKeys'; 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 + import { noteKeys } from '@/features/notes/lib/noteKeys'; 6 + import { feedKeys } from '@/features/feeds/lib/feedKeys'; 7 8 export default function useRemoveCardFromLibrary() { 9 const queryClient = useQueryClient(); ··· 15 16 onSuccess: () => { 17 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 18 + queryClient.invalidateQueries({ queryKey: noteKeys.all() }); 19 + queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 20 queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 21 }, 22 });
+3
src/webapp/features/cards/lib/mutations/useUpdateCardAssociations.tsx
··· 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 import { noteKeys } from '@/features/notes/lib/noteKeys'; 6 import { sembleKeys } from '@/features/semble/lib/sembleKeys'; 7 8 export default function useUpdateCardAssociations() { 9 const client = createSembleClient(); ··· 28 onSuccess: (_data, variables) => { 29 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 30 queryClient.invalidateQueries({ queryKey: noteKeys.all() }); 31 queryClient.invalidateQueries({ queryKey: sembleKeys.all() }); 32 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 33 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 34 35 // invalidate each collection query individually 36 variables.addToCollectionIds?.forEach((id) => {
··· 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 import { noteKeys } from '@/features/notes/lib/noteKeys'; 6 import { sembleKeys } from '@/features/semble/lib/sembleKeys'; 7 + import { feedKeys } from '@/features/feeds/lib/feedKeys'; 8 9 export default function useUpdateCardAssociations() { 10 const client = createSembleClient(); ··· 29 onSuccess: (_data, variables) => { 30 queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 31 queryClient.invalidateQueries({ queryKey: noteKeys.all() }); 32 + queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 33 queryClient.invalidateQueries({ queryKey: sembleKeys.all() }); 34 queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 35 queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 36 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 37 38 // invalidate each collection query individually 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 import { getRecordKey } from '@/lib/utils/atproto'; 5 import { getRelativeTime } from '@/lib/utils/time'; 6 import { Avatar, Card, Group, Stack, Text } from '@mantine/core'; 7 import { useRouter } from 'next/navigation'; 8 9 interface Props { ··· 29 } 30 radius={'lg'} 31 p={'sm'} 32 - style={{ cursor: 'pointer' }} 33 > 34 <Stack justify="space-between" h={'100%'}> 35 <Stack gap={0}>
··· 4 import { getRecordKey } from '@/lib/utils/atproto'; 5 import { getRelativeTime } from '@/lib/utils/time'; 6 import { Avatar, Card, Group, Stack, Text } from '@mantine/core'; 7 + import styles from './CollectionCard.module.css'; 8 import { useRouter } from 'next/navigation'; 9 10 interface Props { ··· 30 } 31 radius={'lg'} 32 p={'sm'} 33 + className={styles.root} 34 > 35 <Stack justify="space-between" h={'100%'}> 36 <Stack gap={0}>
+6 -2
src/webapp/features/collections/components/collectionNavItem/CollectionNavItem.tsx
··· 1 import { useNavbarContext } from '@/providers/navbar'; 2 import { Badge, NavLink } from '@mantine/core'; 3 import Link from 'next/link'; 4 import { usePathname } from 'next/navigation'; 5 ··· 11 12 export default function CollectionNavItem(props: Props) { 13 const { toggleMobile } = useNavbarContext(); 14 const pathname = usePathname(); 15 const isActive = pathname === props.url; 16 ··· 20 href={props.url} 21 label={props.name} 22 variant="subtle" 23 - c={isActive ? 'dark' : 'gray'} 24 onClick={toggleMobile} 25 rightSection={ 26 props.cardCount > 0 ? ( 27 <Badge 28 variant={isActive ? 'filled' : 'light'} 29 - color={isActive ? 'dark' : 'gray'} 30 circle 31 > 32 {props.cardCount}
··· 1 import { useNavbarContext } from '@/providers/navbar'; 2 import { Badge, NavLink } from '@mantine/core'; 3 + import { useColorScheme } from '@mantine/hooks'; 4 import Link from 'next/link'; 5 import { usePathname } from 'next/navigation'; 6 ··· 12 13 export default function CollectionNavItem(props: Props) { 14 const { toggleMobile } = useNavbarContext(); 15 + const colorScheme = useColorScheme(); 16 const pathname = usePathname(); 17 const isActive = pathname === props.url; 18 ··· 22 href={props.url} 23 label={props.name} 24 variant="subtle" 25 + c={isActive ? `${colorScheme === 'dark' ? 'white' : 'dark'}` : 'gray'} 26 onClick={toggleMobile} 27 rightSection={ 28 props.cardCount > 0 ? ( 29 <Badge 30 variant={isActive ? 'filled' : 'light'} 31 + color={ 32 + isActive ? `${colorScheme === 'dark' ? 'gray' : 'dark'}` : 'gray' 33 + } 34 circle 35 > 36 {props.cardCount}
+3 -1
src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx
··· 11 Button, 12 Group, 13 Divider, 14 } from '@mantine/core'; 15 import { Fragment, useState } from 'react'; 16 import { useDebouncedValue } from '@mantine/hooks'; ··· 73 74 return ( 75 <Fragment> 76 <Stack gap="xl"> 77 <Stack> 78 <TextInput ··· 92 } 93 /> 94 95 - <ScrollArea.Autosize mah={340} type="auto"> 96 <Stack gap="xs"> 97 {search ? ( 98 <>
··· 11 Button, 12 Group, 13 Divider, 14 + FocusTrap, 15 } from '@mantine/core'; 16 import { Fragment, useState } from 'react'; 17 import { useDebouncedValue } from '@mantine/hooks'; ··· 74 75 return ( 76 <Fragment> 77 + <FocusTrap.InitialFocus /> 78 <Stack gap="xl"> 79 <Stack> 80 <TextInput ··· 94 } 95 /> 96 97 + <ScrollArea.Autosize mah={215} type="auto"> 98 <Stack gap="xs"> 99 {search ? ( 100 <>
+1 -1
src/webapp/features/collections/components/createCollectionDrawer/CreateCollectionDrawer.tsx
··· 78 </Drawer.Title> 79 </Drawer.Header> 80 81 - <Container size={'sm'}> 82 <form onSubmit={handleCreateCollection}> 83 <Stack> 84 <TextInput
··· 78 </Drawer.Title> 79 </Drawer.Header> 80 81 + <Container size={'sm'} p={0}> 82 <form onSubmit={handleCreateCollection}> 83 <Stack> 84 <TextInput
+1 -1
src/webapp/features/collections/components/editCollectionDrawer/EditCollectionDrawer.tsx
··· 72 </Drawer.Title> 73 </Drawer.Header> 74 75 - <Container size="sm"> 76 <form onSubmit={handleUpdateCollection}> 77 <Stack> 78 <TextInput
··· 72 </Drawer.Title> 73 </Drawer.Header> 74 75 + <Container size="sm" p={0}> 76 <form onSubmit={handleUpdateCollection}> 77 <Stack> 78 <TextInput
+1 -1
src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
··· 7 8 export default function ComposerDrawer() { 9 const { mobileOpened, desktopOpened } = useNavbarContext(); 10 - const isDesktop = useMediaQuery('(min-width: 36em)'); // "sm" breakpoint 11 const isNavOpen = isDesktop ? desktopOpened : mobileOpened; 12 const shouldShowFab = !isNavOpen; 13 const [opened, setOpened] = useState(false);
··· 7 8 export default function ComposerDrawer() { 9 const { mobileOpened, desktopOpened } = useNavbarContext(); 10 + const isDesktop = useMediaQuery('(min-width: 36em)', false); // "sm" breakpoint 11 const isNavOpen = isDesktop ? desktopOpened : mobileOpened; 12 const shouldShowFab = !isNavOpen; 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'; 2 import { FeedItem, Collection } from '@/api-client'; 3 import { Fragment } from 'react'; 4 import Link from 'next/link'; 5 import { getRelativeTime } from '@/lib/utils/time'; 6 import { getRecordKey } from '@/lib/utils/atproto'; 7 import { sanitizeText } from '@/lib/utils/text'; 8 9 interface Props { 10 user: FeedItem['user']; ··· 13 } 14 15 export default function FeedActivityStatus(props: Props) { 16 const MAX_DISPLAYED = 2; 17 const time = getRelativeTime(props.createdAt.toString()); 18 const relativeCreatedDate = time === 'just now' ? `Now` : `${time} ago`; ··· 20 const renderActivityText = () => { 21 const collections = props.collections ?? []; 22 const displayedCollections = collections.slice(0, MAX_DISPLAYED); 23 const remainingCount = collections.length - MAX_DISPLAYED; 24 25 return ( 26 - <Text fw={500} c={'gray.7'}> 27 <Anchor 28 component={Link} 29 href={`/profile/${props.user.handle}`} 30 - c="blue" 31 fw={600} 32 > 33 {sanitizeText(props.user.name)} ··· 52 </span> 53 ), 54 )} 55 - {remainingCount > 0 && 56 - ` and ${remainingCount} other collection${remainingCount > 1 ? 's' : ''}`} 57 </Fragment> 58 )} 59 <Text fz={'sm'} fw={600} c={'gray'} span display={'block'}> ··· 64 }; 65 66 return ( 67 - <Paper bg={'gray.1'} radius={'lg'}> 68 <Stack gap={'xs'}> 69 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 70 <Avatar ··· 76 {renderActivityText()} 77 </Group> 78 </Stack> 79 - </Paper> 80 ); 81 }
··· 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'; 13 import { FeedItem, Collection } from '@/api-client'; 14 import { Fragment } from 'react'; 15 import Link from 'next/link'; 16 import { getRelativeTime } from '@/lib/utils/time'; 17 import { getRecordKey } from '@/lib/utils/atproto'; 18 import { sanitizeText } from '@/lib/utils/text'; 19 + import { useColorScheme } from '@mantine/hooks'; 20 + import { BiCollection } from 'react-icons/bi'; 21 22 interface Props { 23 user: FeedItem['user']; ··· 26 } 27 28 export default function FeedActivityStatus(props: Props) { 29 + const colorScheme = useColorScheme(); 30 const MAX_DISPLAYED = 2; 31 const time = getRelativeTime(props.createdAt.toString()); 32 const relativeCreatedDate = time === 'just now' ? `Now` : `${time} ago`; ··· 34 const renderActivityText = () => { 35 const collections = props.collections ?? []; 36 const displayedCollections = collections.slice(0, MAX_DISPLAYED); 37 + const remainingCollections = collections.slice( 38 + MAX_DISPLAYED, 39 + collections.length, 40 + ); 41 const remainingCount = collections.length - MAX_DISPLAYED; 42 43 return ( 44 + <Text fw={500} c={'gray'}> 45 <Anchor 46 component={Link} 47 href={`/profile/${props.user.handle}`} 48 + c="dark" 49 fw={600} 50 > 51 {sanitizeText(props.user.name)} ··· 70 </span> 71 ), 72 )} 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 + )} 105 </Fragment> 106 )} 107 <Text fz={'sm'} fw={600} c={'gray'} span display={'block'}> ··· 112 }; 113 114 return ( 115 + <Card p={0} bg={colorScheme === 'dark' ? 'dark.4' : 'gray.1'} radius={'lg'}> 116 <Stack gap={'xs'}> 117 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 118 <Avatar ··· 124 {renderActivityText()} 125 </Group> 126 </Stack> 127 + </Card> 128 ); 129 }
+12 -3
src/webapp/features/feeds/components/feedItem/Skeleton.FeedItem.tsx
··· 1 import UrlCardSkeleton from '@/features/cards/components/urlCard/Skeleton.UrlCard'; 2 - import { Avatar, Group, Paper, Skeleton, Stack } from '@mantine/core'; 3 4 export default function FeedItemSkeleton() { 5 return ( 6 <Stack gap={'xs'} align="stretch"> 7 {/* Feed activity status*/} 8 - <Paper bg={'gray.1'} radius={'lg'}> 9 <Stack gap={'xs'} align="stretch" w={'100%'}> 10 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 11 <Avatar /> ··· 15 </Stack> 16 </Group> 17 </Stack> 18 - </Paper> 19 20 <UrlCardSkeleton /> 21 </Stack>
··· 1 + 'use client'; 2 + 3 import UrlCardSkeleton from '@/features/cards/components/urlCard/Skeleton.UrlCard'; 4 + import { Avatar, Card, Group, Paper, Skeleton, Stack } from '@mantine/core'; 5 + import { useColorScheme } from '@mantine/hooks'; 6 7 export default function FeedItemSkeleton() { 8 + const colorScheme = useColorScheme(); 9 + 10 return ( 11 <Stack gap={'xs'} align="stretch"> 12 {/* Feed activity status*/} 13 + <Card 14 + p={0} 15 + bg={colorScheme === 'dark' ? 'dark.4' : 'gray.1'} 16 + radius={'lg'} 17 + > 18 <Stack gap={'xs'} align="stretch" w={'100%'}> 19 <Group gap={'xs'} wrap="nowrap" align="center" p={'xs'}> 20 <Avatar /> ··· 24 </Stack> 25 </Group> 26 </Stack> 27 + </Card> 28 29 <UrlCardSkeleton /> 30 </Stack>
+1 -1
src/webapp/features/notes/components/editNoteDrawer/EditNoteDrawer.tsx
··· 68 </Drawer.Title> 69 </Drawer.Header> 70 71 - <Container size="sm"> 72 <form onSubmit={handleUpdateNote}> 73 <Stack> 74 <Textarea
··· 68 </Drawer.Title> 69 </Drawer.Header> 70 71 + <Container size="sm" p={0}> 72 <form onSubmit={handleUpdateNote}> 73 <Stack> 74 <Textarea
+2 -2
src/webapp/features/notes/components/noteCard/NoteCard.tsx
··· 15 const relativeCreateDate = time === 'just now' ? `${time}` : `${time} ago`; 16 17 return ( 18 - <Card p={'sm'} radius={'lg'} withBorder> 19 - <Stack> 20 <Spoiler showLabel={'Read more'} hideLabel={'See less'} maxHeight={200}> 21 <Text fs={'italic'}>{props.note}</Text> 22 </Spoiler>
··· 15 const relativeCreateDate = time === 'just now' ? `${time}` : `${time} ago`; 16 17 return ( 18 + <Card p={'sm'} radius={'lg'} h={'100%'} withBorder> 19 + <Stack justify="space-between" h={'100%'}> 20 <Spoiler showLabel={'Read more'} hideLabel={'See less'} maxHeight={200}> 21 <Text fs={'italic'}>{props.note}</Text> 22 </Spoiler>
+1 -1
src/webapp/features/notes/components/noteCard/Skeleton.NoteCard.tsx
··· 2 3 export default function NoteCardSkeleton() { 4 return ( 5 - <Card p={'sm'} withBorder> 6 <Stack gap={'xs'}> 7 {/* Note */} 8 <Stack gap={5}>
··· 2 3 export default function NoteCardSkeleton() { 4 return ( 5 + <Card p={'sm'} radius={'lg'} withBorder> 6 <Stack gap={'xs'}> 7 {/* Note */} 8 <Stack gap={5}>
+72 -7
src/webapp/features/notes/components/noteCardModal/NoteCardModalContent.tsx
··· 14 } from '@mantine/core'; 15 import { UrlCard, User } from '@semble/types'; 16 import Link from 'next/link'; 17 - import { useState } from 'react'; 18 import useUpdateNote from '../../lib/mutations/useUpdateNote'; 19 import { notifications } from '@mantine/notifications'; 20 21 interface Props { 22 note: UrlCard['note']; 23 cardContent: UrlCard['cardContent']; 24 cardAuthor?: User; ··· 30 const isMyCard = props.cardAuthor?.id === cardStatus.data.card?.author.id; 31 const [note, setNote] = useState(isMyCard ? props.note?.text : ''); 32 const [editMode, setEditMode] = useState(false); 33 34 const updateNote = useUpdateNote(); 35 36 const handleUpdateNote = () => { 37 - if (!props.note || !note) return; 38 39 updateNote.mutate( 40 { ··· 151 </Text> 152 )} 153 </Stack> 154 - {isMyCard && ( 155 <Button 156 variant="light" 157 color="gray" ··· 162 > 163 Edit note 164 </Button> 165 - )} 166 - </Group> 167 - </Stack> 168 - </Card> 169 </Stack> 170 ); 171 }
··· 14 } from '@mantine/core'; 15 import { UrlCard, User } from '@semble/types'; 16 import Link from 'next/link'; 17 + import { Fragment, useState } from 'react'; 18 import useUpdateNote from '../../lib/mutations/useUpdateNote'; 19 import { notifications } from '@mantine/notifications'; 20 + import useRemoveCardFromLibrary from '@/features/cards/lib/mutations/useRemoveCardFromLibrary'; 21 22 interface Props { 23 + onClose: () => void; 24 note: UrlCard['note']; 25 cardContent: UrlCard['cardContent']; 26 cardAuthor?: User; ··· 32 const isMyCard = props.cardAuthor?.id === cardStatus.data.card?.author.id; 33 const [note, setNote] = useState(isMyCard ? props.note?.text : ''); 34 const [editMode, setEditMode] = useState(false); 35 + const [showDeleteWarning, setShowDeleteWarning] = useState(false); 36 37 + const removeNote = useRemoveCardFromLibrary(); 38 const updateNote = useUpdateNote(); 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 + 56 const handleUpdateNote = () => { 57 + if (!props.note || !note) { 58 + props.onClose(); 59 + return; 60 + } 61 + 62 + if (props.note.text === note) { 63 + props.onClose(); 64 + return; 65 + } 66 67 updateNote.mutate( 68 { ··· 179 </Text> 180 )} 181 </Stack> 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> 209 <Button 210 variant="light" 211 color="gray" ··· 216 > 217 Edit note 218 </Button> 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 + )} 234 </Stack> 235 ); 236 }
+6 -1
src/webapp/features/notes/lib/mutations/useUpdateNote.tsx
··· 3 import { cardKeys } from '@/features/cards/lib/cardKeys'; 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 import { feedKeys } from '@/features/feeds/lib/feedKeys'; 6 7 export default function useUpdateNote() { 8 const queryClient = useQueryClient(); ··· 15 onSuccess: (data) => { 16 queryClient.invalidateQueries({ queryKey: cardKeys.card(data.cardId) }); 17 queryClient.invalidateQueries({ queryKey: cardKeys.infinite() }); 18 - queryClient.invalidateQueries({ queryKey: cardKeys.infinite() }); 19 queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 20 queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 21 },
··· 3 import { cardKeys } from '@/features/cards/lib/cardKeys'; 4 import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 import { feedKeys } from '@/features/feeds/lib/feedKeys'; 6 + import { noteKeys } from '../noteKeys'; 7 8 export default function useUpdateNote() { 9 const queryClient = useQueryClient(); ··· 16 onSuccess: (data) => { 17 queryClient.invalidateQueries({ queryKey: cardKeys.card(data.cardId) }); 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() }); 24 queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 25 queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 26 },
+3 -3
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
··· 23 const profile = await getProfile(props.handle); 24 25 return ( 26 - <Container bg={'white'} p={0} size={'xl'}> 27 <MinimalProfileHeaderContainer 28 avatarUrl={profile.avatarUrl} 29 name={profile.name} ··· 69 component="a" 70 href={`https://bsky.app/profile/${profile.handle}`} 71 target="_blank" 72 radius={'xl'} 73 - bg="gray.2" 74 - c={'gray'} 75 leftSection={<FaBluesky />} 76 > 77 {truncateText(profile.handle, 14)}
··· 23 const profile = await getProfile(props.handle); 24 25 return ( 26 + <Container p={0} size={'xl'}> 27 <MinimalProfileHeaderContainer 28 avatarUrl={profile.avatarUrl} 29 name={profile.name} ··· 69 component="a" 70 href={`https://bsky.app/profile/${profile.handle}`} 71 target="_blank" 72 + variant="light" 73 radius={'xl'} 74 + color={'gray'} 75 leftSection={<FaBluesky />} 76 > 77 {truncateText(profile.handle, 14)}
+1 -1
src/webapp/features/profile/components/profileHeader/Skeleton.ProfileHeader.tsx
··· 10 11 export default function ProfileHeaderSkeleton() { 12 return ( 13 - <Container bg={'white'} p={'xs'} size={'xl'}> 14 <Stack gap={'sm'}> 15 <Stack gap={'xl'}> 16 <Grid gutter={'md'} align={'center'} grow>
··· 10 11 export default function ProfileHeaderSkeleton() { 12 return ( 13 + <Container p={'xs'} size={'xl'}> 14 <Stack gap={'sm'}> 15 <Stack gap={'xl'}> 16 <Grid gutter={'md'} align={'center'} grow>
+58 -8
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
··· 6 Menu, 7 Image, 8 Button, 9 } from '@mantine/core'; 10 import useMyProfile from '../../lib/queries/useMyProfile'; 11 import CosmikLogo from '@/assets/cosmik-logo-full.svg'; 12 - import { MdBugReport } from 'react-icons/md'; 13 import { useAuth } from '@/hooks/useAuth'; 14 import { useRouter } from 'next/navigation'; 15 import Link from 'next/link'; ··· 23 const { data, error, isPending } = useMyProfile(); 24 const { logout } = useAuth(); 25 26 const handleLogout = async () => { 27 try { 28 await logout(); ··· 32 } 33 }; 34 35 if (isPending || !data) { 36 - return <Skeleton w={38} h={38} radius={'md'} ml={4} />; 37 } 38 39 if (error) { ··· 46 <Menu.Target> 47 <Button 48 variant="subtle" 49 - color="gray" 50 - c={'dark'} 51 - fz={'md'} 52 - radius={'md'} 53 size="lg" 54 px={3} 55 - fullWidth={true} 56 justify="start" 57 leftSection={<Avatar src={data.avatarUrl} />} 58 > 59 {data.name} 60 </Button> 61 </Menu.Target> 62 <Menu.Dropdown> 63 <Menu.Item 64 component={Link} ··· 90 91 <Menu.Divider /> 92 93 <Menu.Item 94 component="a" 95 href="https://cosmik.network/" 96 target="_blank" 97 > 98 - <Image src={CosmikLogo.src} alt="Cosmik logo" w={'auto'} h={24} /> 99 </Menu.Item> 100 </Menu.Dropdown> 101 </Menu>
··· 6 Menu, 7 Image, 8 Button, 9 + useMantineColorScheme, 10 + useComputedColorScheme, 11 } from '@mantine/core'; 12 import useMyProfile from '../../lib/queries/useMyProfile'; 13 import CosmikLogo from '@/assets/cosmik-logo-full.svg'; 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'; 21 import { useAuth } from '@/hooks/useAuth'; 22 import { useRouter } from 'next/navigation'; 23 import Link from 'next/link'; ··· 31 const { data, error, isPending } = useMyProfile(); 32 const { logout } = useAuth(); 33 34 + const { colorScheme, setColorScheme } = useMantineColorScheme(); 35 + const computedColorScheme = useComputedColorScheme('light', { 36 + getInitialValueInEffect: true, 37 + }); 38 + 39 const handleLogout = async () => { 40 try { 41 await logout(); ··· 45 } 46 }; 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 + 59 if (isPending || !data) { 60 + return <Skeleton w={38} h={38} radius="md" ml={4} />; 61 } 62 63 if (error) { ··· 70 <Menu.Target> 71 <Button 72 variant="subtle" 73 + color={computedColorScheme === 'dark' ? 'gray' : 'dark'} 74 + fz="md" 75 + radius="md" 76 size="lg" 77 px={3} 78 + fullWidth 79 justify="start" 80 leftSection={<Avatar src={data.avatarUrl} />} 81 > 82 {data.name} 83 </Button> 84 </Menu.Target> 85 + 86 <Menu.Dropdown> 87 <Menu.Item 88 component={Link} ··· 114 115 <Menu.Divider /> 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 + 134 <Menu.Item 135 component="a" 136 href="https://cosmik.network/" 137 target="_blank" 138 > 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 + /> 149 </Menu.Item> 150 </Menu.Dropdown> 151 </Menu>
+18 -16
src/webapp/features/profile/components/profileTabs/ProfileTabs.tsx
··· 1 'use client'; 2 3 - import { Group, ScrollAreaAutosize, Tabs } from '@mantine/core'; 4 import TabItem from './TabItem'; 5 import { usePathname } from 'next/navigation'; 6 ··· 16 17 return ( 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> 34 </Tabs> 35 ); 36 }
··· 1 'use client'; 2 3 + import { Group, Paper, ScrollAreaAutosize, Tabs } from '@mantine/core'; 4 import TabItem from './TabItem'; 5 import { usePathname } from 'next/navigation'; 6 ··· 16 17 return ( 18 <Tabs value={currentTab}> 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> 36 </Tabs> 37 ); 38 }
+14 -7
src/webapp/features/profile/components/profileTabs/TabItem.tsx
··· 1 - import { Anchor, Tabs } from '@mantine/core'; 2 import classes from './TabItem.module.css'; 3 - import Link from 'next/link'; 4 5 interface Props { 6 value: string; ··· 9 } 10 11 export default function TabItem(props: Props) { 12 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> 18 ); 19 }
··· 1 + 'use client'; 2 + 3 + import { Tabs } from '@mantine/core'; 4 import classes from './TabItem.module.css'; 5 + import { useRouter } from 'next/navigation'; 6 7 interface Props { 8 value: string; ··· 11 } 12 13 export default function TabItem(props: Props) { 14 + const router = useRouter(); 15 + 16 return ( 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> 25 ); 26 }
+10 -2
src/webapp/features/semble/components/SembleHeader/SembleHeader.tsx
··· 9 Tooltip, 10 Spoiler, 11 Card, 12 } from '@mantine/core'; 13 import Link from 'next/link'; 14 import { getUrlMetadata } from '@/features/cards/lib/dal'; 15 import { getDomain } from '@/lib/utils/link'; 16 import UrlAddedBySummary from '../urlAddedBySummary/UrlAddedBySummary'; 17 - // import SembleActions from '../sembleActions/SembleActions'; 18 19 interface Props { 20 url: string; ··· 22 23 export default async function SembleHeader(props: Props) { 24 const { metadata } = await getUrlMetadata(props.url); 25 26 return ( 27 <Stack gap={'xl'}> ··· 74 /> 75 </Card> 76 )} 77 - {/*<SembleActions url={props.url} />*/} 78 </Stack> 79 </GridCol> 80 </Grid>
··· 9 Tooltip, 10 Spoiler, 11 Card, 12 + Button, 13 } from '@mantine/core'; 14 import Link from 'next/link'; 15 import { getUrlMetadata } from '@/features/cards/lib/dal'; 16 import { getDomain } from '@/lib/utils/link'; 17 import UrlAddedBySummary from '../urlAddedBySummary/UrlAddedBySummary'; 18 + import SembleActions from '../sembleActions/SembleActions'; 19 + import { verifySessionOnServer } from '@/lib/auth/dal.server'; 20 + import GuestSembleActions from '../sembleActions/GusetSembleActions'; 21 22 interface Props { 23 url: string; ··· 25 26 export default async function SembleHeader(props: Props) { 27 const { metadata } = await getUrlMetadata(props.url); 28 + const session = await verifySessionOnServer(); 29 30 return ( 31 <Stack gap={'xl'}> ··· 78 /> 79 </Card> 80 )} 81 + {session ? ( 82 + <SembleActions url={props.url} /> 83 + ) : ( 84 + <GuestSembleActions url={props.url} /> 85 + )} 86 </Stack> 87 </GridCol> 88 </Grid>
+6 -2
src/webapp/features/semble/components/SembleHeader/Skeleton.SembleHeader.tsx
··· 1 - import { Stack, Grid, GridCol, Text, Skeleton } from '@mantine/core'; 2 import UrlAddedBySummarySkeleton from '../urlAddedBySummary/Skeleton.UrlAddedBySummary'; 3 4 export default function SembleHeaderSkeleton() { ··· 25 </Stack> 26 </GridCol> 27 <GridCol span={{ base: 12, sm: 'content' }}> 28 - <Stack gap={'sm'} align="start" flex={1}> 29 <Skeleton h={150} w={300} maw={'100%'} /> 30 31 {/*<SembleActions />*/} 32 </Stack> 33 </GridCol> 34 </Grid>
··· 1 + import { Stack, Grid, GridCol, Text, Skeleton, Group } from '@mantine/core'; 2 import UrlAddedBySummarySkeleton from '../urlAddedBySummary/Skeleton.UrlAddedBySummary'; 3 4 export default function SembleHeaderSkeleton() { ··· 25 </Stack> 26 </GridCol> 27 <GridCol span={{ base: 12, sm: 'content' }}> 28 + <Stack gap={'sm'} align="center" flex={1}> 29 <Skeleton h={150} w={300} maw={'100%'} /> 30 31 {/*<SembleActions />*/} 32 + <Group gap={'xs'}> 33 + <Skeleton w={44} h={44} circle /> 34 + <Skeleton w={131} h={44} radius={'xl'} /> 35 + </Group> 36 </Stack> 37 </GridCol> 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 'use client'; 2 3 import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; 4 - import { Button, Group } from '@mantine/core'; 5 - import { Fragment } from 'react'; 6 import { FiPlus } from 'react-icons/fi'; 7 import { IoMdCheckmark } from 'react-icons/io'; 8 9 interface Props { 10 url: string; ··· 13 export default function SembleActions(props: Props) { 14 const cardStatus = useGetCardFromMyLibrary({ url: props.url }); 15 const isInYourLibrary = cardStatus.data.card?.urlInLibrary; 16 17 if (cardStatus.error) { 18 return null; ··· 20 21 return ( 22 <Fragment> 23 - <Group> 24 <Button 25 variant={isInYourLibrary ? 'default' : 'filled'} 26 size="md" 27 leftSection={ 28 isInYourLibrary ? <IoMdCheckmark size={18} /> : <FiPlus size={18} /> 29 } 30 > 31 {isInYourLibrary ? 'In library' : 'Add to library'} 32 </Button> 33 </Group> 34 </Fragment> 35 ); 36 }
··· 1 'use client'; 2 3 + import AddCardToModal from '@/features/cards/components/addCardToModal/AddCardToModal'; 4 import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; 5 + import { ActionIcon, Button, CopyButton, Group, Tooltip } from '@mantine/core'; 6 + import { notifications } from '@mantine/notifications'; 7 + import { Fragment, useState } from 'react'; 8 import { FiPlus } from 'react-icons/fi'; 9 import { IoMdCheckmark } from 'react-icons/io'; 10 + import { MdIosShare } from 'react-icons/md'; 11 12 interface Props { 13 url: string; ··· 16 export default function SembleActions(props: Props) { 17 const cardStatus = useGetCardFromMyLibrary({ url: props.url }); 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 + : ''; 25 26 if (cardStatus.error) { 27 return null; ··· 29 30 return ( 31 <Fragment> 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> 61 <Button 62 variant={isInYourLibrary ? 'default' : 'filled'} 63 size="md" 64 leftSection={ 65 isInYourLibrary ? <IoMdCheckmark size={18} /> : <FiPlus size={18} /> 66 } 67 + onClick={() => setShowAddToModal(true)} 68 > 69 {isInYourLibrary ? 'In library' : 'Add to library'} 70 </Button> 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 + /> 82 </Fragment> 83 ); 84 }
+1 -1
src/webapp/features/semble/components/sembleTabs/TabItem.tsx
··· 8 9 export default function TabItem(props: Props) { 10 return ( 11 - <TabsTab c={'dark'} value={props.value} className={classes.tab} fw={600}> 12 {props.children} 13 </TabsTab> 14 );
··· 8 9 export default function TabItem(props: Props) { 10 return ( 11 + <TabsTab value={props.value} className={classes.tab} fw={600}> 12 {props.children} 13 </TabsTab> 14 );
+1 -1
src/webapp/features/semble/components/urlAddedBySummary/Skeleton.UrlAddedBySummary.tsx
··· 1 import { Avatar, Group, Skeleton } from '@mantine/core'; 2 3 - export default async function UrlAddedBySummarySkeleton() { 4 return ( 5 <Group gap={'xs'}> 6 <Avatar src={null} />
··· 1 import { Avatar, Group, Skeleton } from '@mantine/core'; 2 3 + export default function UrlAddedBySummarySkeleton() { 4 return ( 5 <Group gap={'xs'}> 6 <Avatar src={null} />
+8 -10
src/webapp/features/semble/containers/sembleCollectionsContainer/Skeleton.SembleCollectionsContainer.tsx
··· 1 - import { Container, SimpleGrid, Stack } from '@mantine/core'; 2 import CollectionCardSkeleton from '@/features/collections/components/collectionCard/Skeleton.CollectionCard'; 3 4 export default function SembleCollectionsContainerSkeleton() { 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> 15 ); 16 }
··· 1 + import { SimpleGrid, Stack } from '@mantine/core'; 2 import CollectionCardSkeleton from '@/features/collections/components/collectionCard/Skeleton.CollectionCard'; 3 4 export default function SembleCollectionsContainerSkeleton() { 5 return ( 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> 13 ); 14 }
+3 -24
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
··· 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'; 4 import { Suspense } from 'react'; 5 import SembleTabs from '../../components/sembleTabs/SembleTabs'; 6 import SembleHeaderSkeleton from '../../components/SembleHeader/Skeleton.SembleHeader'; 7 8 interface Props { 9 url: string; ··· 12 export default async function SembleContainer(props: Props) { 13 return ( 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> 37 <Container px={'xs'} pb={'xs'} size={'xl'}> 38 <Stack gap={'xl'}> 39 <Suspense fallback={<SembleHeaderSkeleton />}>
··· 1 import SembleHeader from '../../components/SembleHeader/SembleHeader'; 2 + import { Container, Stack } from '@mantine/core'; 3 import { Suspense } from 'react'; 4 import SembleTabs from '../../components/sembleTabs/SembleTabs'; 5 import SembleHeaderSkeleton from '../../components/SembleHeader/Skeleton.SembleHeader'; 6 + import SembleHeaderBackground from './SembleHeaderBackground'; 7 8 interface Props { 9 url: string; ··· 12 export default async function SembleContainer(props: Props) { 13 return ( 14 <Container p={0} fluid> 15 + <SembleHeaderBackground /> 16 <Container px={'xs'} pb={'xs'} size={'xl'}> 17 <Stack gap={'xl'}> 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 13 export default function MantineProvider(props: Props) { 14 return ( 15 - <BaseProvider theme={theme}> 16 <Notifications position="bottom-right" /> 17 {props.children} 18 </BaseProvider>
··· 12 13 export default function MantineProvider(props: Props) { 14 return ( 15 + <BaseProvider theme={theme} forceColorScheme="light"> 16 <Notifications position="bottom-right" /> 17 {props.children} 18 </BaseProvider>
+4 -1
src/webapp/styles/theme.tsx
··· 11 Spoiler, 12 TabsTab, 13 Tooltip, 14 - Title, 15 Text, 16 } from '@mantine/core'; 17 18 export const theme = createTheme({ 19 primaryColor: 'tangerine', 20 colors: { 21 tangerine: [
··· 11 Spoiler, 12 TabsTab, 13 Tooltip, 14 Text, 15 } from '@mantine/core'; 16 17 export const theme = createTheme({ 18 + primaryShade: { 19 + light: 6, 20 + dark: 6, 21 + }, 22 primaryColor: 'tangerine', 23 colors: { 24 tangerine: [