diff --git a/packages/api/package.json b/packages/api/package.json index 9d0cfacb6..80c7c523a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -121,6 +121,7 @@ "node": "14.18.x" }, "volta": { - "node": "14.18.x" + "node": "14.18.1", + "yarn": "1.22.18" } } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 29c880397..435d1e2ab 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -792,6 +792,7 @@ export type Mutation = { signup: SignupResult; updateHighlight: UpdateHighlightResult; updateHighlightReply: UpdateHighlightReplyResult; + updateLabel: UpdateLabelResult; updateLinkShareInfo: UpdateLinkShareInfoResult; updateReminder: UpdateReminderResult; updateSharedComment: UpdateSharedCommentResult; @@ -966,6 +967,11 @@ export type MutationUpdateHighlightReplyArgs = { }; +export type MutationUpdateLabelArgs = { + input: UpdateLabelInput; +}; + + export type MutationUpdateLinkShareInfoArgs = { input: UpdateLinkShareInfoInput; }; @@ -1577,6 +1583,32 @@ export type UpdateHighlightSuccess = { highlight: Highlight; }; +export type UpdateLabelError = { + __typename?: 'UpdateLabelError'; + errorCodes: Array; +}; + +export enum UpdateLabelErrorCode { + BadRequest = 'BAD_REQUEST', + Forbidden = 'FORBIDDEN', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type UpdateLabelInput = { + color: Scalars['String']; + description?: InputMaybe; + labelId: Scalars['ID']; + name: Scalars['String']; +}; + +export type UpdateLabelResult = UpdateLabelError | UpdateLabelSuccess; + +export type UpdateLabelSuccess = { + __typename?: 'UpdateLabelSuccess'; + label: Label; +}; + export type UpdateLinkShareInfoError = { __typename?: 'UpdateLinkShareInfoError'; errorCodes: Array; @@ -2095,6 +2127,11 @@ export type ResolversTypes = { UpdateHighlightReplySuccess: ResolverTypeWrapper; UpdateHighlightResult: ResolversTypes['UpdateHighlightError'] | ResolversTypes['UpdateHighlightSuccess']; UpdateHighlightSuccess: ResolverTypeWrapper; + UpdateLabelError: ResolverTypeWrapper; + UpdateLabelErrorCode: UpdateLabelErrorCode; + UpdateLabelInput: UpdateLabelInput; + UpdateLabelResult: ResolversTypes['UpdateLabelError'] | ResolversTypes['UpdateLabelSuccess']; + UpdateLabelSuccess: ResolverTypeWrapper; UpdateLinkShareInfoError: ResolverTypeWrapper; UpdateLinkShareInfoErrorCode: UpdateLinkShareInfoErrorCode; UpdateLinkShareInfoInput: UpdateLinkShareInfoInput; @@ -2326,6 +2363,10 @@ export type ResolversParentTypes = { UpdateHighlightReplySuccess: UpdateHighlightReplySuccess; UpdateHighlightResult: ResolversParentTypes['UpdateHighlightError'] | ResolversParentTypes['UpdateHighlightSuccess']; UpdateHighlightSuccess: UpdateHighlightSuccess; + UpdateLabelError: UpdateLabelError; + UpdateLabelInput: UpdateLabelInput; + UpdateLabelResult: ResolversParentTypes['UpdateLabelError'] | ResolversParentTypes['UpdateLabelSuccess']; + UpdateLabelSuccess: UpdateLabelSuccess; UpdateLinkShareInfoError: UpdateLinkShareInfoError; UpdateLinkShareInfoInput: UpdateLinkShareInfoInput; UpdateLinkShareInfoResult: ResolversParentTypes['UpdateLinkShareInfoError'] | ResolversParentTypes['UpdateLinkShareInfoSuccess']; @@ -2937,6 +2978,7 @@ export type MutationResolvers>; updateHighlight?: Resolver>; updateHighlightReply?: Resolver>; + updateLabel?: Resolver>; updateLinkShareInfo?: Resolver>; updateReminder?: Resolver>; updateSharedComment?: Resolver>; @@ -3257,6 +3299,20 @@ export type UpdateHighlightSuccessResolvers; }; +export type UpdateLabelErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type UpdateLabelResultResolvers = { + __resolveType: TypeResolveFn<'UpdateLabelError' | 'UpdateLabelSuccess', ParentType, ContextType>; +}; + +export type UpdateLabelSuccessResolvers = { + label?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpdateLinkShareInfoErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3551,6 +3607,9 @@ export type Resolvers = { UpdateHighlightReplySuccess?: UpdateHighlightReplySuccessResolvers; UpdateHighlightResult?: UpdateHighlightResultResolvers; UpdateHighlightSuccess?: UpdateHighlightSuccessResolvers; + UpdateLabelError?: UpdateLabelErrorResolvers; + UpdateLabelResult?: UpdateLabelResultResolvers; + UpdateLabelSuccess?: UpdateLabelSuccessResolvers; UpdateLinkShareInfoError?: UpdateLinkShareInfoErrorResolvers; UpdateLinkShareInfoResult?: UpdateLinkShareInfoResultResolvers; UpdateLinkShareInfoSuccess?: UpdateLinkShareInfoSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 0d38c46b1..20055efb9 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -705,6 +705,7 @@ type Mutation { signup(input: SignupInput!): SignupResult! updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult! updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult! + updateLabel(input: UpdateLabelInput!): UpdateLabelResult! updateLinkShareInfo(input: UpdateLinkShareInfoInput!): UpdateLinkShareInfoResult! updateReminder(input: UpdateReminderInput!): UpdateReminderResult! updateSharedComment(input: UpdateSharedCommentInput!): UpdateSharedCommentResult! @@ -1194,6 +1195,30 @@ type UpdateHighlightSuccess { highlight: Highlight! } +type UpdateLabelError { + errorCodes: [UpdateLabelErrorCode!]! +} + +enum UpdateLabelErrorCode { + BAD_REQUEST + FORBIDDEN + NOT_FOUND + UNAUTHORIZED +} + +input UpdateLabelInput { + color: String! + description: String + labelId: ID! + name: String! +} + +union UpdateLabelResult = UpdateLabelError | UpdateLabelSuccess + +type UpdateLabelSuccess { + label: Label! +} + type UpdateLinkShareInfoError { errorCodes: [UpdateLinkShareInfoErrorCode!]! } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 0cf307a32..2186f701b 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -71,6 +71,7 @@ import { updateUserResolver, uploadFileRequestResolver, validateUsernameResolver, + updateLabelResolver, } from './index' import { getShareInfoForArticle } from '../datalayer/links/share_info' import { @@ -132,6 +133,7 @@ export const functionResolvers = { deleteReminder: deleteReminderResolver, setDeviceToken: setDeviceTokenResolver, createLabel: createLabelResolver, + updateLabel: updateLabelResolver, deleteLabel: deleteLabelResolver, login: loginResolver, signup: signupResolver, diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts index 5183674ad..3f428c8f2 100644 --- a/packages/api/src/resolvers/labels/index.ts +++ b/packages/api/src/resolvers/labels/index.ts @@ -12,9 +12,13 @@ import { MutationCreateLabelArgs, MutationDeleteLabelArgs, MutationSetLabelsArgs, + MutationUpdateLabelArgs, SetLabelsError, SetLabelsErrorCode, SetLabelsSuccess, + UpdateLabelError, + UpdateLabelErrorCode, + UpdateLabelSuccess, } from '../../generated/graphql' import { analytics } from '../../utils/analytics' import { env } from '../../env' @@ -42,6 +46,11 @@ export const labelsResolver = authorized( const user = await getRepository(User).findOne({ where: { id: uid }, relations: ['labels'], + order: { + labels: { + createdAt: 'DESC', + }, + }, }) if (!user) { return { @@ -249,3 +258,67 @@ export const setLabelsResolver = authorized< } } }) + +export const updateLabelResolver = authorized< + UpdateLabelSuccess, + UpdateLabelError, + MutationUpdateLabelArgs +>(async (_, { input }, { claims: { uid }, log }) => { + log.info('updateLabelResolver') + + try { + const { name, color, description, labelId } = input + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [UpdateLabelErrorCode.Unauthorized], + } + } + + const label = await getRepository(Label).findOne({ + where: { id: labelId }, + relations: ['user'], + }) + if (!label) { + return { + errorCodes: [UpdateLabelErrorCode.NotFound], + } + } + + const result = await AppDataSource.transaction(async (t) => { + await setClaims(t, uid) + return await t.getRepository(Label).update( + { id: labelId }, + { + name: name, + description: description || undefined, + color: color, + } + ) + }) + + log.info('Updating a label', { + result, + labels: { + source: 'resolver', + resolver: 'updateLabelResolver', + }, + }) + + if (!result) { + log.info('failed to update') + return { + errorCodes: [UpdateLabelErrorCode.BadRequest], + } + } + + log.info('updated successfully') + + return { label: label } + } catch (error) { + log.error('error updating label', error) + return { + errorCodes: [UpdateLabelErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index dc0561077..c31bf5838 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1310,6 +1310,30 @@ const schema = gql` union DeleteLabelResult = DeleteLabelSuccess | DeleteLabelError + input UpdateLabelInput { + labelId: ID! + color: String! + description: String + name: String! + } + + type UpdateLabelSuccess { + label: Label! + } + + enum UpdateLabelErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + FORBIDDEN + } + + type UpdateLabelError { + errorCodes: [UpdateLabelErrorCode!]! + } + + union UpdateLabelResult = UpdateLabelSuccess | UpdateLabelError + input LoginInput { password: String! email: String! @@ -1410,6 +1434,7 @@ const schema = gql` deleteReminder(id: ID!): DeleteReminderResult! setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult! createLabel(input: CreateLabelInput!): CreateLabelResult! + updateLabel(input: UpdateLabelInput!): UpdateLabelResult! deleteLabel(id: ID!): DeleteLabelResult! login(input: LoginInput!): LoginResult! signup(input: SignupInput!): SignupResult! diff --git a/packages/api/test/resolvers/labels.test.ts b/packages/api/test/resolvers/labels.test.ts index 756911f26..9ef4fb7d7 100644 --- a/packages/api/test/resolvers/labels.test.ts +++ b/packages/api/test/resolvers/labels.test.ts @@ -80,7 +80,7 @@ describe('Labels API', () => { user: { id: user.id }, }) expect(res.body.data.labels.labels).to.eql( - labels.map((label) => ({ + labels.reverse().map((label) => ({ id: label.id, name: label.name, color: label.color, diff --git a/packages/web/components/elements/DropdownElements.tsx b/packages/web/components/elements/DropdownElements.tsx index f3b4f5e04..818898f06 100644 --- a/packages/web/components/elements/DropdownElements.tsx +++ b/packages/web/components/elements/DropdownElements.tsx @@ -8,6 +8,7 @@ import { Arrow, Label, } from '@radix-ui/react-dropdown-menu' +import { CSS } from '@stitches/react'; import { styled } from './../tokens/stitches.config' const itemStyles = { @@ -43,7 +44,7 @@ const StyledTriggerItem = styled(TriggerItem, { ...itemStyles, }) -const DropdownContent = styled(Content, { +export const DropdownContent = styled(Content, { minWidth: 130, backgroundColor: '$grayBg', borderRadius: '0.5em', @@ -70,12 +71,16 @@ type DropdownProps = { showArrow?: boolean triggerElement: React.ReactNode children: React.ReactNode + styledArrow?: boolean align?: DropdownAlignment + disabled?: boolean + css?: CSS } export const DropdownSeparator = styled(Separator, { - height: 0, + height: '1px', margin: 0, + backgroundColor: '$grayBorder', }) type DropdownOptionProps = { @@ -91,6 +96,7 @@ export function DropdownOption(props: DropdownOptionProps): JSX.Element { {props.title ?? props.children} + {props.hideSeparator ? null : } ) } @@ -101,12 +107,15 @@ export function Dropdown({ triggerElement, labelText, showArrow = true, + disabled = false, + css }: DropdownProps): JSX.Element { return ( - {triggerElement} + {triggerElement} { + css={css} + onInteractOutside={(event) => { // remove focus from dropdown ;(document.activeElement as HTMLElement).blur() }} diff --git a/packages/web/components/elements/InfoLink.tsx b/packages/web/components/elements/InfoLink.tsx new file mode 100644 index 000000000..0424f0b2e --- /dev/null +++ b/packages/web/components/elements/InfoLink.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link' +import { Info } from 'phosphor-react' +import { Box } from '../elements/LayoutPrimitives' +import { theme } from '../tokens/stitches.config' +import { TooltipWrapped } from './Tooltip' + +type InfoLinkProps = { + href: string +} + +const TooltipStyle = { + backgroundColor: '#F9D354', + color: '#0A0806', +} + +export function InfoLink(props: InfoLinkProps): JSX.Element { + return ( + + + + + + + + + + ) +} diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx index ee39b1497..b368825e9 100644 --- a/packages/web/components/elements/LabelChip.tsx +++ b/packages/web/components/elements/LabelChip.tsx @@ -21,9 +21,11 @@ export function LabelChip(props: LabelChipProps): JSX.Element { margin: '4px', borderRadius: '32px', color: props.color, + fontSize: '12px', + fontWeight: 'bold', padding: '4px 8px 4px 8px', - border: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.40)`, - backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.20)`, + border: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`, + backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.08)`, }} > {props.text} diff --git a/packages/web/components/elements/LabelColorDropdown.tsx b/packages/web/components/elements/LabelColorDropdown.tsx new file mode 100644 index 000000000..6e105beed --- /dev/null +++ b/packages/web/components/elements/LabelColorDropdown.tsx @@ -0,0 +1,327 @@ +import React, { useState } from 'react' +import { styled } from '../tokens/stitches.config' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { HexColorPicker } from 'react-colorful' +import { Button } from './Button' +import { HStack, SpanBox } from './LayoutPrimitives' +import { CaretDown } from 'phosphor-react' +import { StyledText } from './StyledText' +import { + ColorDetailsProps, + LabelColor, + LabelColorDropdownProps, + LabelColorHex, + LabelColorObject, + LabelOptionProps, +} from '../../utils/settings-page/labels/types' +import { labelColorObjects } from '../../utils/settings-page/labels/labelColorObjects' +import { DropdownOption } from './DropdownElements' +import { isDarkTheme } from '../../lib/themeUpdater' + +const DropdownMenuContent = styled(DropdownMenuPrimitive.Content, { + maxWidth: 190, + borderRadius: 6, + backgroundColor: '$grayBg', + padding: 5, + boxShadow: + '0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)', +}) + +const itemStyles = { + all: 'unset', + fontSize: '$3', + lineHeight: 1, + borderRadius: 3, + display: 'flex', + alignItems: 'center', + height: 25, + position: 'relative', + userSelect: 'none', +} + +const DropdownMenuTriggerItem = styled(DropdownMenuPrimitive.TriggerItem, { + '&[data-state="open"]': { + outline: 'none', + backgroundColor: '$grayBgHover', + }, + ...itemStyles, + padding: '$2 0', + '&:focus': { + outline: 'none', + backgroundColor: '$grayBgHover', + }, +}) + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = styled(DropdownMenuPrimitive.Trigger, { + backgroundColor: 'transparent', + border: 0, + padding: 0, + marginRight: '$2', +}) +const Box = styled('div', {}) + +const MainContainer = styled(Box, { + fontFamily: 'inter', + fontSize: '$2', + lineHeight: '1.25', + color: '$grayText', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '$grayBg', + border: '1px solid $grayBorder', + width: '180px', + borderRadius: '$3', + px: '$3', + py: '0px', + cursor: 'pointer', + '&:hover': { + border: '1px solid $grayBorderHover', + }, + '@mdDown': { + width: '100%' + } +}) + +const CustomLabelWrapper = styled(Box, { + fontSize: 13, + padding: '$2', + borderRadius: 3, + cursor: 'default', + color: '$grayText', + + '&:focus': { + outline: 'none', + backgroundColor: '$grayBgHover', + }, +}) + +export const LabelColorDropdown = (props: LabelColorDropdownProps) => { + const { + isCreateMode, + canEdit, + labelColorHexRowId, + labelId, + labelColor, + labelColorHexValue, + setLabelColorHex, + } = props + + const isDarkMode = isDarkTheme() + const iconColor = isDarkMode ? '#FFFFFF': '#0A0806' + const [open, setOpen] = useState(false); + + const handleCustomColorChange = (color: string) => { + setLabelColorHex({ + rowId: labelId, + value: color.toUpperCase() as LabelColor, + }) + } + + const handleOpen = (open: boolean) => { + if (canEdit && open) setOpen(true) + else if((isCreateMode && !canEdit) && open) setOpen(true) + else setOpen(false) + } + + return ( + + + + + {labelId !== '' && labelId === labelColorHexRowId ? ( + + ) : ( + <> + {labelId ? ( + + ) : ( + + )} + + )} + + + + + + + + {Object.keys(labelColorObjects) + .filter((labelColor) => labelColor !== 'custom color') + .map((labelColor) => ( + + setLabelColorHex({ + rowId: labelId, + value: labelColor as LabelColor, + }) + } + > + + + ))} + + + null}> + + + + + + + + + + ) +} + +function LabelOption(props: LabelOptionProps): JSX.Element { + const { color, isDropdownOption, isCreateMode, labelId } = props + // const colorDetails = getColorDetails( + // color as LabelColor, + // labelId, + // Boolean(isCreateMode) + // ) + const isCreating = isCreateMode && !labelId + const { text, border, colorName, background } = getLabelColorObject( + color as LabelColor + ) + + let colorNameText = colorName + if (!labelId && isCreateMode) { + colorNameText = 'Select Color' + colorNameText = isDropdownOption ? colorName : colorNameText + } + + colorNameText = color === 'custom color' ? colorNameText : colorName + + let colorHex = !labelId && isCreateMode && !isDropdownOption ? '' : text + + colorHex = + !labelId && isCreateMode && !isDropdownOption && color !== 'custom color' + ? text + : colorHex + + return ( + + + + + + {colorNameText} + + + {colorNameText === 'custom color' ? '' : colorHex} + + {isDropdownOption ? : null} + + ) +} + +function getLabelColorObject(color: LabelColor) { + if (labelColorObjects[color]) { + return labelColorObjects[color] + } + const colorObject: LabelColorObject = { + colorName: 'Custom', + text: color, + border: color + '66', + background: color + '0D', + } + return colorObject +} + +function LabelColorIcon(props: { + fillColor: string + strokeColor: string +}): JSX.Element { + return ( + + ) +} diff --git a/packages/web/components/elements/StyledText.tsx b/packages/web/components/elements/StyledText.tsx index af008f21c..0df5f3b3b 100644 --- a/packages/web/components/elements/StyledText.tsx +++ b/packages/web/components/elements/StyledText.tsx @@ -29,7 +29,7 @@ const textVariants = { }, }, fixedHeadline: { - fontSize: '$6', + fontSize: '24px', fontWeight: '500', }, subHeadline: { diff --git a/packages/web/components/elements/Tooltip.tsx b/packages/web/components/elements/Tooltip.tsx index 465c76a7c..241c20e8e 100644 --- a/packages/web/components/elements/Tooltip.tsx +++ b/packages/web/components/elements/Tooltip.tsx @@ -67,6 +67,7 @@ export const TooltipWrapped: FC = ({ active, tooltipContent, tooltipSide, + arrowStyles, ...props }) => { return ( @@ -74,7 +75,7 @@ export const TooltipWrapped: FC = ({ {children} {tooltipContent} - + ) diff --git a/packages/web/components/templates/article/EditLabelsModal.tsx b/packages/web/components/templates/article/EditLabelsModal.tsx index c2ebe7786..6b3e043d5 100644 --- a/packages/web/components/templates/article/EditLabelsModal.tsx +++ b/packages/web/components/templates/article/EditLabelsModal.tsx @@ -8,11 +8,10 @@ import { Button } from '../../elements/Button' import { StyledText } from '../../elements/StyledText' import { CrossIcon } from '../../elements/images/CrossIcon' import { theme } from '../../tokens/stitches.config' -import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery' +import { Label, useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery' import { ChangeEvent, useCallback, useState } from 'react' import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation' import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery' -import { Label } from '../../../lib/networking/fragments/labelFragment' import { LabelChip } from '../../elements/LabelChip' type EditLabelsModalProps = { diff --git a/packages/web/components/tokens/stitches.config.ts b/packages/web/components/tokens/stitches.config.ts index c2597afae..6e78f741f 100644 --- a/packages/web/components/tokens/stitches.config.ts +++ b/packages/web/components/tokens/stitches.config.ts @@ -146,6 +146,8 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } = // Avatar Fallback color avatarBg: '#FFFFFF', avatarFont: '#0A0806', + + labelButtonsBg: '#F5F5F4', tooltipIcons: '#FDFAEC' }, }, @@ -191,6 +193,8 @@ const darkThemeSpec = { tooltipIcons: '#5F5E58', avatarBg: '#000000', avatarFont: 'rgba(255, 255, 255, 0.8)', + + labelButtonsBg: '#5F5E58', }, shadows: { cardBoxShadow: '0px 0px 9px -2px rgba(32, 31, 29, 0.09), 0px 7px 12px rgba(32, 31, 29, 0.07)' diff --git a/packages/web/lib/networking/mutations/deleteLabelMutation.ts b/packages/web/lib/networking/mutations/deleteLabelMutation.ts index cef0b4d53..988bac3f5 100644 --- a/packages/web/lib/networking/mutations/deleteLabelMutation.ts +++ b/packages/web/lib/networking/mutations/deleteLabelMutation.ts @@ -1,6 +1,16 @@ import { gql } from 'graphql-request' +import { Label } from '../fragments/labelFragment' import { gqlFetcher } from '../networkHelpers' +type DeleteLabelResult = { + deleteLabel: DeleteLabel + errorCodes?: unknown[] +} + +type DeleteLabel = { + label: Label +} + export async function deleteLabelMutation( labelId: string ): Promise { @@ -24,9 +34,8 @@ export async function deleteLabelMutation( ` try { - const data = await gqlFetcher(mutation) - console.log('deleted label', data) - return data + const data = await gqlFetcher(mutation) as DeleteLabelResult + return data.errorCodes ? undefined : data.deleteLabel.label.id } catch (error) { console.log('deleteLabelMutation error', error) return undefined diff --git a/packages/web/lib/networking/mutations/updateLabelMutation.ts b/packages/web/lib/networking/mutations/updateLabelMutation.ts new file mode 100644 index 000000000..d05f11b35 --- /dev/null +++ b/packages/web/lib/networking/mutations/updateLabelMutation.ts @@ -0,0 +1,49 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' + +export type UpdateLabelInput = { + labelId: string + name: string, + color: string, + description?: string +} + +export async function updateLabelMutation( + input: UpdateLabelInput +): Promise { + const mutation = gql` + mutation { + updateLabel( + input: { + color: "${input.color}" + name: "${input.name}" + description: "${input.description}" + labelId: "${input.labelId}" + } + ) { + ... on UpdateLabelSuccess { + label { + id + name + color + description + createdAt + } + } + ... on UpdateLabelError { + errorCodes + } + } + } + ` + + try { + const data = await gqlFetcher(mutation) + console.log(input, data); + const output = data as any + console.log(output) + return output?.updatedLabel + } catch (err) { + return undefined + } +} diff --git a/packages/web/lib/networking/queries/useGetLabelsQuery.tsx b/packages/web/lib/networking/queries/useGetLabelsQuery.tsx index 211c53178..c625b592e 100644 --- a/packages/web/lib/networking/queries/useGetLabelsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetLabelsQuery.tsx @@ -1,6 +1,7 @@ import { gql } from 'graphql-request' import useSWR from 'swr' -import { Label, labelFragment } from '../fragments/labelFragment' +import { LabelColor } from '../../../utils/settings-page/labels/types'; +import { labelFragment } from '../fragments/labelFragment' import { publicGqlFetcher } from '../networkHelpers' type LabelsQueryResponse = { @@ -17,6 +18,14 @@ type LabelsData = { labels?: unknown } +export type Label = { + id: string + name: string + color: LabelColor + description?: string + createdAt: Date +} + export function useGetLabelsQuery(): LabelsQueryResponse { const query = gql` query GetLabels { diff --git a/packages/web/package.json b/packages/web/package.json index e5e0c9d77..7858d890b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,6 +35,7 @@ "pspdfkit": "^2021.6.0", "react": "^17.0.2", "react-apple-login": "^1.1.3", + "react-colorful": "^5.5.1", "react-dom": "^17.0.2", "react-hot-toast": "^2.1.1", "react-intl": "^5.20.12", diff --git a/packages/web/pages/[username]/[slug]/index.tsx b/packages/web/pages/[username]/[slug]/index.tsx index f06475756..a341789af 100644 --- a/packages/web/pages/[username]/[slug]/index.tsx +++ b/packages/web/pages/[username]/[slug]/index.tsx @@ -124,7 +124,7 @@ export default function Home(): JSX.Element { articleReadingProgressMutation, }} /> - {showLabelsModal && ( + {/* {showLabelsModal && ( - )} + )} */} )} diff --git a/packages/web/pages/settings/emails.tsx b/packages/web/pages/settings/emails.tsx index f3901e4c2..229552e92 100644 --- a/packages/web/pages/settings/emails.tsx +++ b/packages/web/pages/settings/emails.tsx @@ -18,12 +18,13 @@ import { VStack, } from '../../components/elements/LayoutPrimitives' import { useCopyLink } from '../../lib/hooks/useCopyLink' -import { toast, Toaster } from 'react-hot-toast' +import { Toaster } from 'react-hot-toast' import { useCallback } from 'react' import { StyledText } from '../../components/elements/StyledText' import { applyStoredTheme } from '../../lib/themeUpdater' import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' import Link from 'next/link' +import { InfoLink } from '../../components/elements/InfoLink' enum TextType { EmailAddress, @@ -36,7 +37,7 @@ type CopyTextButtonProps = { } const HeaderWrapper = styled(Box, { - width: '863px', + width: '100%', '@md': { display: 'block', }, @@ -55,7 +56,6 @@ const TableCard = styled(Box, { }, '@md': { paddingLeft: '0', - width: '863px', }, }) @@ -66,7 +66,7 @@ const TableHeading = styled(Box, { alignItems: 'center', padding: '14px 0 14px 40px', borderRadius: '5px 5px 0px 0px', - width: '863px', + width: '100%', '@md': { display: 'flex', } @@ -89,16 +89,6 @@ const CopyTextBtnWrapper = styled(Box, { border: '1px solid rgba(0, 0, 0, 0.06)', }) -const MobileBtnWrapper = styled(Box, { - display: 'flex', - position: 'fixed', - bottom: '16px', - right: '25px', - '@md': { - display: 'none', - }, -}) - const InfoIcon = styled(Info, { marginTop: '8px', '&:hover': { @@ -218,11 +208,9 @@ export default function EmailsPage(): JSX.Element { color: '$grayText', paddingBottom: '5rem', paddingTop: '2rem', - '@md': { m: '16px', alignSelf: 'center', - maxWidth: '72%', mx: '42px', paddingTop: '0', }, @@ -233,49 +221,41 @@ export default function EmailsPage(): JSX.Element { Email Addresses{' '} - - - - - - - - - - - - + + + + + - )} - + ) })} - - - + ) } diff --git a/packages/web/pages/settings/labels.tsx b/packages/web/pages/settings/labels.tsx index 64f0916ad..9a96ab15e 100644 --- a/packages/web/pages/settings/labels.tsx +++ b/packages/web/pages/settings/labels.tsx @@ -1,32 +1,200 @@ +import { useEffect, useState } from 'react' import { PrimaryLayout } from '../../components/templates/PrimaryLayout' import { Button } from '../../components/elements/Button' -import { Box, VStack } from '../../components/elements/LayoutPrimitives' +import { styled, theme } from '../../components/tokens/stitches.config' +import { + Box, + SpanBox, + HStack, + VStack, +} from '../../components/elements/LayoutPrimitives' import { Toaster } from 'react-hot-toast' import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery' import { createLabelMutation } from '../../lib/networking/mutations/createLabelMutation' +import { updateLabelMutation } from '../../lib/networking/mutations/updateLabelMutation' import { deleteLabelMutation } from '../../lib/networking/mutations/deleteLabelMutation' -import { useState } from 'react' -import { applyStoredTheme } from '../../lib/themeUpdater' +import { applyStoredTheme, isDarkTheme } from '../../lib/themeUpdater' import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { Label } from '../../lib/networking/queries/useGetLabelsQuery' + +import { StyledText } from '../../components/elements/StyledText' +import { + ArrowClockwise, + DotsThree, + PencilSimple, + Trash, + Plus, +} from 'phosphor-react' +import { + LabelColor, + GenericTableCardProps, + LabelColorHex, +} from '../../utils/settings-page/labels/types' +import { labelColorObjects, } from '../../utils/settings-page/labels/labelColorObjects' +import { + TooltipWrapped +} from '../../components/elements/Tooltip' +import { LabelColorDropdown } from '../../components/elements/LabelColorDropdown' +import { + Dropdown, + DropdownOption, +} from '../../components/elements/DropdownElements' +import { LabelChip } from '../../components/elements/LabelChip' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' +import { InfoLink } from '../../components/elements/InfoLink' + +const HeaderWrapper = styled(Box, { + width: '100%', +}) + +const TableCard = styled(Box, { + padding: '0px', + backgroundColor: '$grayBg', + display: 'flex', + alignItems: 'center', + border: '0.3px solid $grayBgActive', + width: '100%', + '@md': { + paddingLeft: '0', + }, +}) + +const TableCardBox = styled(Box, { + display: 'grid', + width: '100%', + gridGap: '$1', + gridTemplateColumns: '3fr 1fr', + '.showHidden': { + display: 'none', + }, + '&:hover': { + '.showHidden': { + display: 'unset', + gridColumn: 'span 2', + width: '100%', + padding: '$2 $3 0 $3', + }, + }, + '@md': { + gridTemplateColumns: '20% 15% 1fr 1fr 1fr', + '&:hover': { + '.showHidden': { + display: 'none', + }, + }, + }, +}) + +const inputStyles = { + backgroundColor: 'transparent', + color: '$grayTextContrast', + padding: '6px 6px', + margin: '$2 0', + border: '1px solid $grayBorder', + borderRadius: '6px', + fontSize: '16px', + FontFamily: '$fontFamily', + width: '100%', + '@md': { + width: 'auto', + minWidth: '180px', + }, + '&[disabled]': { + border: 'none', + }, + '&:focus': { + outlineColor: '$omnivoreYellow', + outlineStyle: 'solid', + }, +} + +const ActionsWrapper = styled(Box, { + mr: '$1', + display: 'flex', + width: 40, + height: 40, + alignItems: 'center', + bg: 'transparent', + cursor: 'pointer', + fontFamily: 'inter', + fontSize: '$2', + lineHeight: '1.25', + color: '$grayText', + '&:hover': { + opacity: 0.8, + }, +}) + +const IconButton = styled(Button, { + variants: { + style: { + ctaWhite: { + color: 'red', + padding: '10px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: '1px solid $grayBorder', + boxSizing: 'border-box', + borderRadius: 6, + width: 40, + height: 40, + }, + }, + }, +}) + +const Input = styled('input', { ...inputStyles }) + +const TextArea = styled('textarea', { ...inputStyles }) export default function LabelsPage(): JSX.Element { - const { labels, revalidate, isValidating } = useGetLabelsQuery() - const [name, setName] = useState('') - const [color, setColor] = useState('') - const [description, setDescription] = useState('') + const { labels, revalidate } = useGetLabelsQuery() + const [labelColorHex, setLabelColorHex] = useState({ + rowId: '', + value: 'custom color', + }) + const [editingLabelId, setEditingLabelId] = useState(null) + const [nameInputText, setNameInputText] = useState('') + const [descriptionInputText, setDescriptionInputText] = useState('') + const [isCreateMode, setIsCreateMode] = useState(false) + const [windowWidth, setWindowWidth] = useState(0) + const [confirmRemoveLabelId, setConfirmRemoveLabelId] = useState(null) + const breakpoint = 768 applyStoredTheme(false) + useEffect(() => { + const handleResizeWindow = () => setWindowWidth(window.innerWidth) + if (windowWidth === 0) { + setWindowWidth(window.innerWidth) + } + window.addEventListener('resize', handleResizeWindow) + return () => { + window.removeEventListener('resize', handleResizeWindow) + } + }, [windowWidth]) + + const resetLabelState = () => { + setIsCreateMode(false) + setEditingLabelId('') + setNameInputText('') + setDescriptionInputText('') + setLabelColorHex({ rowId: '', value: 'custom color' }) + } + async function createLabel(): Promise { - const res = await createLabelMutation(name, color, description) + const res = await createLabelMutation( + nameInputText, + labelColorHex.value, + descriptionInputText + ) if (res) { if (res.createLabel.errorCodes && res.createLabel.errorCodes.length > 0) { showErrorToast(res.createLabel.errorCodes[0]) } else { - showSuccessToast('Label created') - setName('') - setColor('') - setDescription('') + showSuccessToast('Label created', { position: 'bottom-right' }) + resetLabelState() revalidate() } } else { @@ -34,78 +202,704 @@ export default function LabelsPage(): JSX.Element { } } - async function deleteLabel(id: string): Promise { - await deleteLabelMutation(id) + async function updateLabel(id: string): Promise { + await updateLabelMutation({ + labelId: id, + name: nameInputText, + color: labelColorHex.value, + description: descriptionInputText, + }) revalidate() } + const onEditPress = (label : Label | null) => { + if (label) { + setEditingLabelId(label.id) + setNameInputText(label.name) + setDescriptionInputText(label.description || '') + setLabelColorHex({ rowId: '', value: label.color }) + } + else { + resetLabelState() + } + } + + async function onDeleteLabel(id: string): Promise { + const result = await deleteLabelMutation(id) + if (result) { + showSuccessToast('Label deleted', { position: 'bottom-right' }) + } else { + showErrorToast('Failed to delete label', { position: 'bottom-right' }) + } + revalidate() + } + + async function deleteLabel(id: string): Promise { + setConfirmRemoveLabelId(id) + } + + const handleGenerateRandomColor = (rowId?: string) => { + const colorHexes = Object.keys(labelColorObjects).slice( + 0, + -1 + ) as LabelColor[] + const randomColorHex = + colorHexes[Math.floor(Math.random() * colorHexes.length)] + setLabelColorHex((prevState) => ({ + ...prevState, + rowId: rowId || '', + value: randomColorHex, + })) + } + return ( - - -

Create a new label

-
=> { - e.preventDefault() - await createLabel() - }} - > - { - setName(event.target.value) - }} + + + {confirmRemoveLabelId ? ( + { + onDeleteLabel(confirmRemoveLabelId) + setConfirmRemoveLabelId(null)} + } + onOpenChange={() => setConfirmRemoveLabelId(null)} /> - { - setColor(event.target.value) - }} - /> - { - setDescription(event.target.value) - }} - /> - - - -

Labels

- {labels && - labels.map((label) => { - return ( - -
=> { - e.preventDefault() - await deleteLabel(label.id) - }} - > - - - - -
-
+ + )} +
+
+ + <> + {isCreateMode ? ( + windowWidth > breakpoint ? ( + + ) : ( + ) - })} + ) : null} + + {labels + ? labels.map((label, i) => { + const isLastChild = i === labels.length - 1 + const isFirstChild = i === 0 + const cardProps = { + label: label, + labelColorHex: labelColorHex, + editingLabelId: editingLabelId, + isCreateMode: isCreateMode, + isLastChild: isLastChild, + isFirstChild: isFirstChild, + handleGenerateRandomColor: handleGenerateRandomColor, + setEditingLabelId: setEditingLabelId, + setLabelColorHex: setLabelColorHex, + deleteLabel: deleteLabel, + nameInputText: nameInputText, + descriptionInputText: descriptionInputText, + setNameInputText: setNameInputText, + setDescriptionInputText: setDescriptionInputText, + setIsCreateMode: setIsCreateMode, + createLabel: createLabel, + resetState: resetLabelState, + updateLabel: updateLabel, + } + + if (editingLabelId == label.id) { + if (windowWidth >= breakpoint) { + return + } else { + return + } + } + + return () + }) : null} + ) } + +function GenericTableCard(props: GenericTableCardProps & { isLastChild?: boolean; isFirstChild?: boolean}) { + const { + label, + isLastChild, + isFirstChild, + editingLabelId, + labelColorHex, + isCreateMode, + nameInputText, + descriptionInputText, + handleGenerateRandomColor, + setLabelColorHex, + setEditingLabelId, + deleteLabel, + setNameInputText, + setDescriptionInputText, + createLabel, + updateLabel, + onEditPress, + resetState, + } = props + const showInput = + editingLabelId === label?.id || (isCreateMode && !label) + const labelColor = editingLabelId === label?.id ? labelColorHex.value : label?.color + const iconColor = isDarkTheme() ? '#D8D7D5': '#5F5E58' + + const handleEdit = () => { + editingLabelId && updateLabel(editingLabelId) + setEditingLabelId(null) + } + + const moreActionsButton = () => { + return ( + + } + > + null}> + + + null}> + + + + + ) + } + + return ( + + + + {(showInput && !label) ? null : ( + + + + )} + {(showInput && !label) ? ( + + setNameInputText(event.target.value)} + required + autoFocus + /> + + ) : null} + + + + {showInput ? ( + setDescriptionInputText(event.target.value)} + autoFocus={!!label} + /> + ) : ( + + {editingLabelId === label?.id ? descriptionInputText : label?.description || ''} + + )} + + + + {showInput && ( + + )} + {showInput && ( + + + handleGenerateRandomColor(label?.id)} + disabled={!(isCreateMode && !label) && !(editingLabelId === label?.id)} + > + + + + + )} + {!showInput && ( + + {moreActionsButton()} + + )} + + + + {showInput && ( + setDescriptionInputText(event.target.value)} + autoFocus={!!label} + /> + )} + + + + {editingLabelId === label?.id || !label ? ( + <> + + + + ) : ( + + onEditPress(label)} + disabled={isCreateMode} + > + + + deleteLabel(label.id)} + disabled={isCreateMode} + > + + + + )} + + + + ) +} + +function MobileEditCard(props: any) { + const { + label, + editingLabelId, + labelColorHex, + isCreateMode, + nameInputText, + descriptionInputText, + setLabelColorHex, + setEditingLabelId, + setNameInputText, + setDescriptionInputText, + createLabel, + resetState, + updateLabel, + isFirstChild, + isLastChild, + } = props + + const handleEdit = () => { + editingLabelId && updateLabel(editingLabelId) + setEditingLabelId(null) + } + + return ( + + + {nameInputText && ( + + + + )} + setNameInputText(event.target.value)} + autoFocus + /> + +