diff options
author | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 18:55:39 -0400 |
---|---|---|
committer | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 18:55:39 -0400 |
commit | 2657469964e24ffbeb905024532120395f6e797c (patch) | |
tree | 2fe9db8a4ecfa92d854ca94f7586d81163c8bd25 /src/views/Settings | |
download | donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.tar.gz donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.tar.bz2 donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.zip |
move to Donetick Org, First commit frontend
Diffstat (limited to '')
-rw-r--r-- | src/views/Settings/APITokenSettings.jsx | 130 | ||||
-rw-r--r-- | src/views/Settings/NotificationSetting.jsx | 90 | ||||
-rw-r--r-- | src/views/Settings/Settings.jsx | 384 | ||||
-rw-r--r-- | src/views/Settings/Sharing.jsx | 0 | ||||
-rw-r--r-- | src/views/Settings/SharingSettings.jsx | 0 | ||||
-rw-r--r-- | src/views/Settings/ThemeToggle.jsx | 62 |
6 files changed, 666 insertions, 0 deletions
diff --git a/src/views/Settings/APITokenSettings.jsx b/src/views/Settings/APITokenSettings.jsx new file mode 100644 index 0000000..5bd9887 --- /dev/null +++ b/src/views/Settings/APITokenSettings.jsx @@ -0,0 +1,130 @@ +import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' +import moment from 'moment' +import { useContext, useEffect, useState } from 'react' +import { UserContext } from '../../contexts/UserContext' +import { + CreateLongLiveToken, + DeleteLongLiveToken, + GetLongLiveTokens, +} from '../../utils/Fetcher' +import { isPlusAccount } from '../../utils/Helpers' +import TextModal from '../Modals/Inputs/TextModal' + +const APITokenSettings = () => { + const [tokens, setTokens] = useState([]) + const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false) + const { userProfile, setUserProfile } = useContext(UserContext) + useEffect(() => { + GetLongLiveTokens().then(resp => { + resp.json().then(data => { + setTokens(data.res) + }) + }) + }, []) + + const handleSaveToken = name => { + CreateLongLiveToken(name).then(resp => { + if (resp.ok) { + resp.json().then(data => { + // add the token to the list: + console.log(data) + const newTokens = [...tokens] + newTokens.push(data.res) + setTokens(newTokens) + }) + } + }) + } + + return ( + <div className='grid gap-4 py-4' id='apitokens'> + <Typography level='h3'>Long Live Token</Typography> + <Divider /> + <Typography level='body-sm'> + Create token to use with the API to update things that trigger task or + chores + </Typography> + {!isPlusAccount(userProfile) && ( + <Chip variant='soft' color='warning'> + Not available in Basic Plan + </Chip> + )} + + {tokens.map(token => ( + <Card key={token.token} className='p-4'> + <Box sx={{ display: 'flex', justifyContent: 'space-between' }}> + <Box> + <Typography level='body-md'>{token.name}</Typography> + <Typography level='body-xs'> + {moment(token.createdAt).fromNow()}( + {moment(token.createdAt).format('lll')}) + </Typography> + </Box> + <Box> + {token.token && ( + <Button + variant='outlined' + color='primary' + sx={{ mr: 1 }} + onClick={() => { + navigator.clipboard.writeText(token.token) + alert('Token copied to clipboard') + }} + > + Copy Token + </Button> + )} + + <Button + variant='outlined' + color='danger' + onClick={() => { + const confirmed = confirm( + `Are you sure you want to remove ${token.name} ?`, + ) + if (confirmed) { + DeleteLongLiveToken(token.id).then(resp => { + if (resp.ok) { + alert('Token removed') + const newTokens = tokens.filter(t => t.id !== token.id) + setTokens(newTokens) + } + }) + } + }} + > + Remove + </Button> + </Box> + </Box> + </Card> + ))} + + <Button + variant='soft' + color='primary' + disabled={!isPlusAccount(userProfile)} + sx={{ + width: '210px', + mb: 1, + }} + onClick={() => { + setIsGetTokenNameModalOpen(true) + }} + > + Generate New Token + </Button> + <TextModal + isOpen={isGetTokenNameModalOpen} + title='Give a name for your new token, something to remember it by.' + onClose={() => { + setIsGetTokenNameModalOpen(false) + }} + okText={'Generate Token'} + onSave={handleSaveToken} + /> + </div> + ) +} + +export default APITokenSettings diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx new file mode 100644 index 0000000..4ead3b9 --- /dev/null +++ b/src/views/Settings/NotificationSetting.jsx @@ -0,0 +1,90 @@ +import { Button, Divider, Input, Option, Select, Typography } from '@mui/joy' +import { useContext, useEffect, useState } from 'react' +import { UserContext } from '../../contexts/UserContext' +import { GetUserProfile, UpdateUserDetails } from '../../utils/Fetcher' + +const NotificationSetting = () => { + const { userProfile, setUserProfile } = useContext(UserContext) + useEffect(() => { + if (!userProfile) { + GetUserProfile().then(resp => { + resp.json().then(data => { + setUserProfile(data.res) + setChatID(data.res.chatID) + }) + }) + } + }, []) + const [chatID, setChatID] = useState(userProfile?.chatID) + + return ( + <div className='grid gap-4 py-4' id='notifications'> + <Typography level='h3'>Notification Settings</Typography> + <Divider /> + <Typography level='body-md'>Manage your notification settings</Typography> + + <Select defaultValue='telegram' sx={{ maxWidth: '200px' }} disabled> + <Option value='telegram'>Telegram</Option> + <Option value='discord'>Discord</Option> + </Select> + + <Typography level='body-xs'> + You need to initiate a message to the bot in order for the Telegram + notification to work{' '} + <a + style={{ + textDecoration: 'underline', + color: '#0891b2', + }} + href='https://t.me/DonetickBot' + > + Click here + </a>{' '} + to start a chat + </Typography> + + <Input + value={chatID} + onChange={e => setChatID(e.target.value)} + placeholder='User ID / Chat ID' + sx={{ + width: '200px', + }} + /> + <Typography mt={0} level='body-xs'> + If you don't know your Chat ID, start chat with userinfobot and it will + send you your Chat ID.{' '} + <a + style={{ + textDecoration: 'underline', + color: '#0891b2', + }} + href='https://t.me/userinfobot' + > + Click here + </a>{' '} + to start chat with userinfobot{' '} + </Typography> + + <Button + sx={{ + width: '110px', + mb: 1, + }} + onClick={() => { + UpdateUserDetails({ + chatID: Number(chatID), + }).then(resp => { + resp.json().then(data => { + setUserProfile(data) + }) + }) + }} + > + Save + </Button> + </div> + ) +} + +export default NotificationSetting diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx new file mode 100644 index 0000000..d612eec --- /dev/null +++ b/src/views/Settings/Settings.jsx @@ -0,0 +1,384 @@ +import { + Box, + Button, + Card, + Chip, + CircularProgress, + Container, + Divider, + Input, + Typography, +} from '@mui/joy' +import moment from 'moment' +import { useContext, useEffect, useState } from 'react' +import { UserContext } from '../../contexts/UserContext' +import Logo from '../../Logo' +import { + AcceptCircleMemberRequest, + CancelSubscription, + DeleteCircleMember, + GetAllCircleMembers, + GetCircleMemberRequests, + GetSubscriptionSession, + GetUserCircle, + GetUserProfile, + JoinCircle, + LeaveCircle, +} from '../../utils/Fetcher' +import APITokenSettings from './APITokenSettings' +import NotificationSetting from './NotificationSetting' +import ThemeToggle from './ThemeToggle' + +const Settings = () => { + const { userProfile, setUserProfile } = useContext(UserContext) + const [userCircles, setUserCircles] = useState([]) + const [circleMemberRequests, setCircleMemberRequests] = useState([]) + const [circleInviteCode, setCircleInviteCode] = useState('') + const [circleMembers, setCircleMembers] = useState([]) + useEffect(() => { + GetUserProfile().then(resp => { + resp.json().then(data => { + setUserProfile(data.res) + }) + }) + GetUserCircle().then(resp => { + resp.json().then(data => { + setUserCircles(data.res ? data.res : []) + }) + }) + GetCircleMemberRequests().then(resp => { + resp.json().then(data => { + setCircleMemberRequests(data.res ? data.res : []) + }) + }) + GetAllCircleMembers() + .then(res => res.json()) + .then(data => { + setCircleMembers(data.res ? data.res : []) + }) + }, []) + + useEffect(() => { + const hash = window.location.hash + if (hash) { + const sharingSection = document.getElementById( + window.location.hash.slice(1), + ) + if (sharingSection) { + sharingSection.scrollIntoView({ behavior: 'smooth' }) + } + } + }, []) + + const getSubscriptionDetails = () => { + if (userProfile?.subscription === 'active') { + return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment( + userProfile?.expiration, + ).format('MMM DD, YYYY')}.` + } else if (userProfile?.subscription === 'canceled') { + return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment( + userProfile?.expiration, + ).format('MMM DD, YYYY')}.` + } else { + return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.` + } + } + const getSubscriptionStatus = () => { + if (userProfile?.subscription === 'active') { + return `Plus` + } else if (userProfile?.subscription === 'canceled') { + if (moment().isBefore(userProfile?.expiration)) { + return `Plus(until ${moment(userProfile?.expiration).format( + 'MMM DD, YYYY', + )})` + } + return `Free` + } else { + return `Free` + } + } + + if (userProfile === null) { + return ( + <Container className='flex h-full items-center justify-center'> + <Box className='flex flex-col items-center justify-center'> + <CircularProgress + color='success' + sx={{ '--CircularProgress-size': '200px' }} + > + <Logo /> + </CircularProgress> + </Box> + </Container> + ) + } + return ( + <Container> + <div className='grid gap-4 py-4' id='sharing'> + <Typography level='h3'>Sharing settings</Typography> + <Divider /> + <Typography level='body-md'> + Your account is automatically connected to a Circle when you create or + join one. Easily invite friends by sharing the unique Circle code or + link below. You'll receive a notification below when someone requests + to join your Circle. + </Typography> + <Typography level='title-sm' mb={-1}> + {userCircles[0]?.userRole === 'member' + ? `You part of ${userCircles[0]?.name} ` + : `You circle code is:`} + + <Input + value={userCircles[0]?.invite_code} + disabled + size='lg' + sx={{ + width: '220px', + mb: 1, + }} + /> + <Button + variant='soft' + onClick={() => { + navigator.clipboard.writeText(userCircles[0]?.invite_code) + alert('Code Copied to clipboard') + }} + > + Copy Code + </Button> + <Button + variant='soft' + sx={{ ml: 1 }} + onClick={() => { + navigator.clipboard.writeText( + window.location.protocol + + '//' + + window.location.host + + `/circle/join?code=${userCircles[0]?.invite_code}`, + ) + alert('Link Copied to clipboard') + }} + > + Copy Link + </Button> + {userCircles.length > 0 && userCircles[0]?.userRole === 'member' && ( + <Button + sx={{ ml: 1 }} + onClick={() => { + const confirmed = confirm( + `Are you sure you want to leave your circle?`, + ) + if (confirmed) { + LeaveCircle(userCircles[0]?.id).then(resp => { + if (resp.ok) { + alert('Left circle successfully.') + } else { + alert('Failed to leave circle.') + } + }) + } + }} + > + Leave Circle + </Button> + )} + </Typography> + <Typography level='title-md'>Circle Members</Typography> + {circleMembers.map(member => ( + <Card key={member.id} className='p-4'> + <Box sx={{ display: 'flex', justifyContent: 'space-between' }}> + <Box> + <Typography level='body-md'> + {member.displayName.charAt(0).toUpperCase() + + member.displayName.slice(1)} + {member.userId === userProfile.id ? '(You)' : ''}{' '} + <Chip> + {' '} + {member.isActive ? member.role : 'Pending Approval'} + </Chip> + </Typography> + {member.isActive ? ( + <Typography level='body-sm'> + Joined on {moment(member.createdAt).format('MMM DD, YYYY')} + </Typography> + ) : ( + <Typography level='body-sm' color='danger'> + Request to join{' '} + {moment(member.updatedAt).format('MMM DD, YYYY')} + </Typography> + )} + </Box> + {member.userId !== userProfile.id && member.isActive && ( + <Button + disabled={ + circleMembers.find(m => userProfile.id == m.userId).role !== + 'admin' + } + variant='outlined' + color='danger' + size='sm' + onClick={() => { + const confirmed = confirm( + `Are you sure you want to remove ${member.displayName} from your circle?`, + ) + if (confirmed) { + DeleteCircleMember(member.circleId, member.userId).then( + resp => { + if (resp.ok) { + alert('Removed member successfully.') + } + }, + ) + } + }} + > + Remove + </Button> + )} + </Box> + </Card> + ))} + + {circleMemberRequests.length > 0 && ( + <Typography level='title-md'>Circle Member Requests</Typography> + )} + {circleMemberRequests.map(request => ( + <Card key={request.id} className='p-4'> + <Typography level='body-md'> + {request.displayName} wants to join your circle. + </Typography> + <Button + variant='soft' + color='success' + onClick={() => { + const confirmed = confirm( + `Are you sure you want to accept ${request.displayName}(username:${request.username}) to join your circle?`, + ) + if (confirmed) { + AcceptCircleMemberRequest(request.id).then(resp => { + if (resp.ok) { + alert('Accepted request successfully.') + // reload the page + window.location.reload() + } + }) + } + }} + > + Accept + </Button> + </Card> + ))} + <Divider> or </Divider> + + <Typography level='body-md'> + if want to join someone else's Circle? Ask them for their unique + Circle code or join link. Enter the code below to join their Circle. + </Typography> + + <Typography level='title-sm' mb={-1}> + Enter Circle code: + <Input + placeholder='Enter code' + value={circleInviteCode} + onChange={e => setCircleInviteCode(e.target.value)} + size='lg' + sx={{ + width: '220px', + mb: 1, + }} + /> + <Button + variant='soft' + onClick={() => { + const confirmed = confirm( + `Are you sure you want to leave you circle and join '${circleInviteCode}'?`, + ) + if (confirmed) { + JoinCircle(circleInviteCode).then(resp => { + if (resp.ok) { + alert( + 'Joined circle successfully, wait for the circle owner to accept your request.', + ) + } + }) + } + }} + > + Join Circle + </Button> + </Typography> + </div> + + <div className='grid gap-4 py-4' id='account'> + <Typography level='h3'>Account Settings</Typography> + <Divider /> + <Typography level='body-md'> + Change your account settings, including your password, display name + </Typography> + <Typography level='title-md' mb={-1}> + Account Type : {getSubscriptionStatus()} + </Typography> + <Typography level='body-sm'>{getSubscriptionDetails()}</Typography> + <Box> + <Button + sx={{ + width: '110px', + mb: 1, + }} + disabled={ + userProfile?.subscription === 'active' || + moment(userProfile?.expiration).isAfter(moment()) + } + onClick={() => { + GetSubscriptionSession().then(data => { + data.json().then(data => { + console.log(data) + window.location.href = data.sessionURL + // open in new window: + // window.open(data.sessionURL, '_blank') + }) + }) + }} + > + Upgrade + </Button> + + {userProfile?.subscription === 'active' && ( + <Button + sx={{ + width: '110px', + mb: 1, + ml: 1, + }} + variant='outlined' + onClick={() => { + CancelSubscription().then(resp => { + if (resp.ok) { + alert('Subscription cancelled.') + window.location.reload() + } + }) + }} + > + Cancel + </Button> + )} + </Box> + </div> + <NotificationSetting /> + <APITokenSettings /> + <div className='grid gap-4 py-4'> + <Typography level='h3'>Theme preferences</Typography> + <Divider /> + <Typography level='body-md'> + Choose how the site looks to you. Select a single theme, or sync with + your system and automatically switch between day and night themes. + </Typography> + <ThemeToggle /> + </div> + </Container> + ) +} + +export default Settings diff --git a/src/views/Settings/Sharing.jsx b/src/views/Settings/Sharing.jsx new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/views/Settings/Sharing.jsx diff --git a/src/views/Settings/SharingSettings.jsx b/src/views/Settings/SharingSettings.jsx new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/views/Settings/SharingSettings.jsx diff --git a/src/views/Settings/ThemeToggle.jsx b/src/views/Settings/ThemeToggle.jsx new file mode 100644 index 0000000..6ff33f1 --- /dev/null +++ b/src/views/Settings/ThemeToggle.jsx @@ -0,0 +1,62 @@ +import useStickyState from '@/hooks/useStickyState' +import { + DarkModeOutlined, + LaptopOutlined, + LightModeOutlined, +} from '@mui/icons-material' +import { + Button, + FormControl, + FormLabel, + ToggleButtonGroup, + useColorScheme, +} from '@mui/joy' + +const ELEMENTID = 'select-theme-mode' + +const ThemeToggle = () => { + const { mode, setMode } = useColorScheme() + const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode') + + const handleThemeModeChange = (_, newThemeMode) => { + if (!newThemeMode) return + setThemeMode(newThemeMode) + setMode(newThemeMode) + } + + const FormThemeModeToggleLabel = () => ( + <FormLabel + level='title-md' + id={`${ELEMENTID}-label`} + htmlFor='select-theme-mode' + > + Theme mode + </FormLabel> + ) + + return ( + <FormControl> + <FormThemeModeToggleLabel /> + <div className='flex items-center gap-4'> + <ToggleButtonGroup + id={ELEMENTID} + variant='outlined' + value={themeMode} + onChange={handleThemeModeChange} + > + <Button startDecorator={<LightModeOutlined />} value='light'> + Light + </Button> + <Button startDecorator={<DarkModeOutlined />} value='dark'> + Dark + </Button> + <Button startDecorator={<LaptopOutlined />} value='system'> + System + </Button> + </ToggleButtonGroup> + </div> + </FormControl> + ) +} + +export default ThemeToggle |