feat: GUI to create smart views (#1997)
This commit is contained in:
@@ -15,8 +15,7 @@ export interface PredicateJsonForm {
|
|||||||
export const AllPredicateCompoundOperators = ['and', 'or'] as const
|
export const AllPredicateCompoundOperators = ['and', 'or'] as const
|
||||||
export type PredicateCompoundOperator = typeof AllPredicateCompoundOperators[number]
|
export type PredicateCompoundOperator = typeof AllPredicateCompoundOperators[number]
|
||||||
|
|
||||||
export const AllPredicateOperators = [
|
export const AllNonCompoundPredicateOperators = [
|
||||||
...AllPredicateCompoundOperators,
|
|
||||||
'!=',
|
'!=',
|
||||||
'=',
|
'=',
|
||||||
'<',
|
'<',
|
||||||
@@ -30,6 +29,8 @@ export const AllPredicateOperators = [
|
|||||||
'includes',
|
'includes',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const AllPredicateOperators = [...AllPredicateCompoundOperators, ...AllNonCompoundPredicateOperators] as const
|
||||||
|
|
||||||
export type PredicateOperator = typeof AllPredicateOperators[number]
|
export type PredicateOperator = typeof AllPredicateOperators[number]
|
||||||
|
|
||||||
export type SureValue = number | number[] | string[] | string | Date | boolean | false | ''
|
export type SureValue = number | number[] | string[] | string | Date | boolean | false | ''
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export function valueMatchesTargetValue(
|
|||||||
value = value.toLowerCase()
|
value = value.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date && typeof targetValue === 'string') {
|
||||||
|
targetValue = new Date(targetValue)
|
||||||
|
}
|
||||||
|
|
||||||
if (operator === 'not') {
|
if (operator === 'not') {
|
||||||
return !valueMatchesTargetValue(value, '=', targetValue)
|
return !valueMatchesTargetValue(value, '=', targetValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,6 +429,36 @@ describe('predicates', () => {
|
|||||||
it('hours ago value', () => {
|
it('hours ago value', () => {
|
||||||
expect(new Predicate('updated_at', '>', '1.hours.ago').matchesItem(item)).toEqual(true)
|
expect(new Predicate('updated_at', '>', '1.hours.ago').matchesItem(item)).toEqual(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('nonmatching hours ago value', () => {
|
||||||
|
expect(new Predicate('updated_at', '<', '1.hours.ago').matchesItem(item)).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('months ago value', () => {
|
||||||
|
expect(new Predicate('updated_at', '>', '1.months.ago').matchesItem(item)).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nonmatching months ago value', () => {
|
||||||
|
expect(new Predicate('updated_at', '<', '1.months.ago').matchesItem(item)).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('years ago value', () => {
|
||||||
|
expect(new Predicate('updated_at', '>', '1.years.ago').matchesItem(item)).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nonmatching years ago value', () => {
|
||||||
|
expect(new Predicate('updated_at', '<', '1.years.ago').matchesItem(item)).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('string date value', () => {
|
||||||
|
item = createItem({}, new Date('01/01/2022'))
|
||||||
|
expect(new Predicate('updated_at', '<', '01/02/2022').matchesItem(item)).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nonmatching string date value', () => {
|
||||||
|
item = createItem({}, new Date('01/01/2022'))
|
||||||
|
expect(new Predicate('updated_at', '>', '01/02/2022').matchesItem(item)).toEqual(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('nonexistent properties', () => {
|
describe('nonexistent properties', () => {
|
||||||
|
|||||||
25
packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts
Normal file
25
packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { dateFromDSLDateString } from './Utils'
|
||||||
|
|
||||||
|
describe('Predicate Utils', () => {
|
||||||
|
describe('dateFromDSLDateString', () => {
|
||||||
|
it('should return a date object with the correct day', () => {
|
||||||
|
const date = dateFromDSLDateString('1.days.ago')
|
||||||
|
expect(date.getDate()).toEqual(new Date().getDate() - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a date object with the correct hour', () => {
|
||||||
|
const date = dateFromDSLDateString('1.hours.ago')
|
||||||
|
expect(date.getHours()).toEqual(new Date().getHours() - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a date object with the correct month', () => {
|
||||||
|
const date = dateFromDSLDateString('1.months.ago')
|
||||||
|
expect(date.getMonth()).toEqual(new Date().getMonth() - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a date object with the correct year', () => {
|
||||||
|
const date = dateFromDSLDateString('1.years.ago')
|
||||||
|
expect(date.getFullYear()).toEqual(new Date().getFullYear() - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,10 @@ export function dateFromDSLDateString(string: string): Date {
|
|||||||
date.setDate(date.getDate() - offset)
|
date.setDate(date.getDate() - offset)
|
||||||
} else if (unit === 'hours') {
|
} else if (unit === 'hours') {
|
||||||
date.setHours(date.getHours() - offset)
|
date.setHours(date.getHours() - offset)
|
||||||
|
} else if (unit === 'months') {
|
||||||
|
date.setMonth(date.getMonth() - offset)
|
||||||
|
} else if (unit === 'years') {
|
||||||
|
date.setFullYear(date.getFullYear() - offset)
|
||||||
}
|
}
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,4 +148,10 @@ export interface ItemsClientInterface {
|
|||||||
* @returns Whether the item is a template (unmanaged)
|
* @returns Whether the item is a template (unmanaged)
|
||||||
*/
|
*/
|
||||||
isTemplateItem(item: DecryptedItemInterface): boolean
|
isTemplateItem(item: DecryptedItemInterface): boolean
|
||||||
|
|
||||||
|
createSmartView<T extends DecryptedItemInterface<ItemContent>>(
|
||||||
|
title: string,
|
||||||
|
predicate: PredicateInterface<T>,
|
||||||
|
iconString?: string,
|
||||||
|
): Promise<SmartView>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1222,12 +1222,14 @@ export class ItemManager
|
|||||||
public async createSmartView<T extends Models.DecryptedItemInterface>(
|
public async createSmartView<T extends Models.DecryptedItemInterface>(
|
||||||
title: string,
|
title: string,
|
||||||
predicate: Models.PredicateInterface<T>,
|
predicate: Models.PredicateInterface<T>,
|
||||||
|
iconString?: string,
|
||||||
): Promise<Models.SmartView> {
|
): Promise<Models.SmartView> {
|
||||||
return this.createItem(
|
return this.createItem(
|
||||||
ContentType.SmartView,
|
ContentType.SmartView,
|
||||||
Models.FillItemContent({
|
Models.FillItemContent({
|
||||||
title,
|
title,
|
||||||
predicate: predicate.toJson(),
|
predicate: predicate.toJson(),
|
||||||
|
iconString: iconString || 'restore',
|
||||||
} as Models.SmartViewContent),
|
} as Models.SmartViewContent),
|
||||||
true,
|
true,
|
||||||
) as Promise<Models.SmartView>
|
) as Promise<Models.SmartView>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Icon from '@/Components/Icon/Icon'
|
|||||||
import { IconType } from '@standardnotes/snjs'
|
import { IconType } from '@standardnotes/snjs'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClick: () => void
|
onClick: MouseEventHandler<HTMLButtonElement>
|
||||||
className?: string
|
className?: string
|
||||||
icon: IconType
|
icon: IconType
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
@@ -21,9 +21,9 @@ const IconButton: FunctionComponent<Props> = ({
|
|||||||
iconClassName = '',
|
iconClassName = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const click: MouseEventHandler = (e) => {
|
const click: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onClick()
|
onClick(e)
|
||||||
}
|
}
|
||||||
const focusableClass = focusable ? '' : 'focus:shadow-none'
|
const focusableClass = focusable ? '' : 'focus:shadow-none'
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ import { DisplayOptionsMenuProps } from './DisplayOptionsMenuProps'
|
|||||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||||
import NewNotePreferences from './NewNotePreferences'
|
import NewNotePreferences from './NewNotePreferences'
|
||||||
import { PreferenceMode } from './PreferenceMode'
|
import { PreferenceMode } from './PreferenceMode'
|
||||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
|
|
||||||
import Button from '@/Components/Button/Button'
|
|
||||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscriptionBanner'
|
||||||
|
|
||||||
const DailyEntryModeEnabled = true
|
const DailyEntryModeEnabled = true
|
||||||
|
|
||||||
@@ -200,30 +199,6 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoSubscriptionBanner = () => (
|
|
||||||
<div className="m-2 mt-2 mb-3 grid grid-cols-1 rounded-md border border-border p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Icon className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)} type={PremiumFeatureIconName} />
|
|
||||||
<h1 className="sk-h3 m-0 text-sm font-semibold">Upgrade for per-tag preferences</h1>
|
|
||||||
</div>
|
|
||||||
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
|
|
||||||
{DailyEntryModeEnabled &&
|
|
||||||
'Create powerful workflows and organizational layouts with per-tag display preferences and the all-new Daily Notebook calendar layout.'}
|
|
||||||
{!DailyEntryModeEnabled &&
|
|
||||||
'Create powerful workflows and organizational layouts with per-tag display preferences.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
primary
|
|
||||||
small
|
|
||||||
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
|
|
||||||
onClick={() => application.openPurchaseFlow()}
|
|
||||||
>
|
|
||||||
Upgrade Features
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu className="text-sm" a11yLabel="Notes list options menu" closeMenu={closeDisplayOptionsMenu} isOpen={isOpen}>
|
<Menu className="text-sm" a11yLabel="Notes list options menu" closeMenu={closeDisplayOptionsMenu} isOpen={isOpen}>
|
||||||
<div className="my-1 px-3 text-base font-semibold uppercase text-text lg:text-xs">Preferences for</div>
|
<div className="my-1 px-3 text-base font-semibold uppercase text-text lg:text-xs">Preferences for</div>
|
||||||
@@ -239,7 +214,18 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{controlsDisabled && <NoSubscriptionBanner />}
|
{controlsDisabled && (
|
||||||
|
<NoSubscriptionBanner
|
||||||
|
className="m-2 mt-2 mb-3"
|
||||||
|
application={application}
|
||||||
|
title="Upgrade for per-tag preferences"
|
||||||
|
message={
|
||||||
|
DailyEntryModeEnabled
|
||||||
|
? 'Create powerful workflows and organizational layouts with per-tag display preferences and the all-new Daily Notebook calendar layout.'
|
||||||
|
: 'Create powerful workflows and organizational layouts with per-tag display preferences.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<MenuItemSeparator />
|
<MenuItemSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
import { EmojiString, Platform, VectorIconNameOrEmoji } from '@standardnotes/snjs'
|
import { EmojiString, Platform, VectorIconNameOrEmoji } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent, useMemo, useRef, useState } from 'react'
|
import { FunctionComponent, useMemo, useRef, useState } from 'react'
|
||||||
import Dropdown from '../Dropdown/Dropdown'
|
import Dropdown from '../Dropdown/Dropdown'
|
||||||
import { DropdownItem } from '../Dropdown/DropdownItem'
|
import { DropdownItem } from '../Dropdown/DropdownItem'
|
||||||
import { getEmojiLength } from './EmojiLength'
|
import { getEmojiLength } from './EmojiLength'
|
||||||
import { isIconEmoji } from './Icon'
|
import Icon, { isIconEmoji } from './Icon'
|
||||||
import { IconNameToSvgMapping } from './IconNameToSvgMapping'
|
import { IconNameToSvgMapping } from './IconNameToSvgMapping'
|
||||||
import { IconPickerType } from './IconPickerType'
|
import { IconPickerType } from './IconPickerType'
|
||||||
|
|
||||||
@@ -11,13 +12,26 @@ type Props = {
|
|||||||
selectedValue: VectorIconNameOrEmoji
|
selectedValue: VectorIconNameOrEmoji
|
||||||
onIconChange: (value?: string) => void
|
onIconChange: (value?: string) => void
|
||||||
platform: Platform
|
platform: Platform
|
||||||
|
useIconGrid?: boolean
|
||||||
|
iconGridClassName?: string
|
||||||
|
portalDropdown?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props) => {
|
const IconPicker = ({
|
||||||
|
selectedValue,
|
||||||
|
onIconChange,
|
||||||
|
platform,
|
||||||
|
className,
|
||||||
|
useIconGrid,
|
||||||
|
portalDropdown,
|
||||||
|
iconGridClassName,
|
||||||
|
}: Props) => {
|
||||||
|
const iconKeys = useMemo(() => Object.keys(IconNameToSvgMapping), [])
|
||||||
|
|
||||||
const iconOptions = useMemo(
|
const iconOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...Object.keys(IconNameToSvgMapping)].map(
|
iconKeys.map(
|
||||||
(value) =>
|
(value) =>
|
||||||
({
|
({
|
||||||
label: value,
|
label: value,
|
||||||
@@ -25,7 +39,7 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
|
|||||||
icon: value,
|
icon: value,
|
||||||
} as DropdownItem),
|
} as DropdownItem),
|
||||||
),
|
),
|
||||||
[],
|
[iconKeys],
|
||||||
)
|
)
|
||||||
|
|
||||||
const isSelectedEmoji = isIconEmoji(selectedValue)
|
const isSelectedEmoji = isIconEmoji(selectedValue)
|
||||||
@@ -91,16 +105,36 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
|
|||||||
<TabButton label="Reset" type={'reset'} />
|
<TabButton label="Reset" type={'reset'} />
|
||||||
</div>
|
</div>
|
||||||
<div className={'mt-2 h-full min-h-0 overflow-auto'}>
|
<div className={'mt-2 h-full min-h-0 overflow-auto'}>
|
||||||
{currentType === 'icon' && (
|
{currentType === 'icon' &&
|
||||||
<Dropdown
|
(useIconGrid ? (
|
||||||
fullWidth={true}
|
<div
|
||||||
id="change-tag-icon-dropdown"
|
className={classNames(
|
||||||
label="Change the icon for a tag"
|
'flex w-full flex-wrap items-center gap-6 p-1 md:max-h-24 md:gap-4 md:p-0',
|
||||||
items={iconOptions}
|
iconGridClassName,
|
||||||
value={selectedValue}
|
)}
|
||||||
onChange={handleIconChange}
|
>
|
||||||
/>
|
{iconKeys.map((iconName) => (
|
||||||
)}
|
<button
|
||||||
|
key={iconName}
|
||||||
|
onClick={() => {
|
||||||
|
handleIconChange(iconName)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type={iconName} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Dropdown
|
||||||
|
fullWidth={true}
|
||||||
|
id="change-tag-icon-dropdown"
|
||||||
|
label="Change the icon for a tag"
|
||||||
|
items={iconOptions}
|
||||||
|
value={selectedValue}
|
||||||
|
onChange={handleIconChange}
|
||||||
|
portal={portalDropdown}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{currentType === 'emoji' && (
|
{currentType === 'emoji' && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||||
|
|
||||||
|
const NoSubscriptionBanner = ({
|
||||||
|
application,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
application: WebApplication
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
className?: string
|
||||||
|
}) => (
|
||||||
|
<div className={classNames('grid grid-cols-1 rounded-md border border-border p-4', className)}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Icon className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)} type={PremiumFeatureIconName} />
|
||||||
|
<h1 className="sk-h3 m-0 text-sm font-semibold">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">{message}</p>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
small
|
||||||
|
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
|
||||||
|
onClick={() => application.openPurchaseFlow()}
|
||||||
|
>
|
||||||
|
Upgrade Features
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default NoSubscriptionBanner
|
||||||
@@ -10,6 +10,7 @@ import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSe
|
|||||||
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
||||||
import PlaintextDefaults from './PlaintextDefaults'
|
import PlaintextDefaults from './PlaintextDefaults'
|
||||||
import Persistence from './Persistence'
|
import Persistence from './Persistence'
|
||||||
|
import SmartViews from './SmartViews/SmartViews'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
viewControllerManager: ViewControllerManager
|
viewControllerManager: ViewControllerManager
|
||||||
@@ -22,6 +23,11 @@ const General: FunctionComponent<Props> = ({ viewControllerManager, application,
|
|||||||
<Persistence application={application} />
|
<Persistence application={application} />
|
||||||
<PlaintextDefaults application={application} />
|
<PlaintextDefaults application={application} />
|
||||||
<Defaults application={application} />
|
<Defaults application={application} />
|
||||||
|
<SmartViews
|
||||||
|
application={application}
|
||||||
|
navigationController={viewControllerManager.navigationController}
|
||||||
|
featuresController={viewControllerManager.featuresController}
|
||||||
|
/>
|
||||||
<Tools application={application} />
|
<Tools application={application} />
|
||||||
<LabsPane application={application} />
|
<LabsPane application={application} />
|
||||||
<Advanced
|
<Advanced
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import IconPicker from '@/Components/Icon/IconPicker'
|
||||||
|
import Popover from '@/Components/Popover/Popover'
|
||||||
|
import ModalDialog from '@/Components/Shared/ModalDialog'
|
||||||
|
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
|
||||||
|
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
|
||||||
|
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
|
||||||
|
import Spinner from '@/Components/Spinner/Spinner'
|
||||||
|
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||||
|
import { SmartView, TagMutator } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
navigationController: NavigationController
|
||||||
|
view: SmartView
|
||||||
|
closeDialog: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditSmartViewModal = ({ application, navigationController, view, closeDialog }: Props) => {
|
||||||
|
const [title, setTitle] = useState(view.title)
|
||||||
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [selectedIcon, setSelectedIcon] = useState<string | undefined>(view.iconString)
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
|
||||||
|
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const toggleIconPicker = useCallback(() => {
|
||||||
|
setShouldShowIconPicker((shouldShow) => !shouldShow)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveSmartView = useCallback(async () => {
|
||||||
|
if (!title.length) {
|
||||||
|
titleInputRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
await application.mutator.changeAndSaveItem<TagMutator>(view, (mutator) => {
|
||||||
|
mutator.title = title
|
||||||
|
mutator.iconString = selectedIcon || 'restore'
|
||||||
|
})
|
||||||
|
|
||||||
|
setIsSaving(false)
|
||||||
|
closeDialog()
|
||||||
|
}, [application.mutator, closeDialog, selectedIcon, title, view])
|
||||||
|
|
||||||
|
const deleteSmartView = useCallback(async () => {
|
||||||
|
void navigationController.remove(view, true)
|
||||||
|
closeDialog()
|
||||||
|
}, [closeDialog, navigationController, view])
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
closeDialog()
|
||||||
|
}, [closeDialog])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={close}>Edit Smart View "{view.title}"</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="text-sm font-semibold">Title:</div>
|
||||||
|
<input
|
||||||
|
className="rounded border border-border bg-default py-1 px-2"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value)
|
||||||
|
}}
|
||||||
|
ref={titleInputRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="text-sm font-semibold">Icon:</div>
|
||||||
|
<button
|
||||||
|
className="rounded border border-border p-2"
|
||||||
|
aria-label="Change icon"
|
||||||
|
onClick={toggleIconPicker}
|
||||||
|
ref={iconPickerButtonRef}
|
||||||
|
>
|
||||||
|
<Icon type={selectedIcon || 'restore'} />
|
||||||
|
</button>
|
||||||
|
<Popover
|
||||||
|
open={shouldShowIconPicker}
|
||||||
|
anchorElement={iconPickerButtonRef.current}
|
||||||
|
togglePopover={toggleIconPicker}
|
||||||
|
align="start"
|
||||||
|
overrideZIndex="z-modal"
|
||||||
|
>
|
||||||
|
<div className="p-2">
|
||||||
|
<IconPicker
|
||||||
|
selectedValue={selectedIcon || 'restore'}
|
||||||
|
onIconChange={(value?: string | undefined) => {
|
||||||
|
setSelectedIcon(value)
|
||||||
|
toggleIconPicker()
|
||||||
|
}}
|
||||||
|
platform={application.platform}
|
||||||
|
useIconGrid={true}
|
||||||
|
portalDropdown={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons>
|
||||||
|
<Button className="mr-auto" disabled={isSaving} onClick={deleteSmartView} colorStyle="danger">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isSaving} onClick={saveSmartView}>
|
||||||
|
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isSaving} onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(EditSmartViewModal)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import { SmartView } from '@standardnotes/snjs'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
view: SmartView
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SmartViewItem = ({ view, onEdit, onDelete }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-1.5">
|
||||||
|
<Icon type={view.iconString} size="custom" className="h-5.5 w-5.5" />
|
||||||
|
<span className="mr-auto text-sm">{view.title}</span>
|
||||||
|
<Button small onClick={onEdit}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button small onClick={onDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SmartViewItem
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||||
|
import { isSystemView, SmartView } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Title } from '../../../PreferencesComponents/Content'
|
||||||
|
import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup'
|
||||||
|
import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment'
|
||||||
|
import AddSmartViewModal from '@/Components/SmartViewBuilder/AddSmartViewModal'
|
||||||
|
import { AddSmartViewModalController } from '@/Components/SmartViewBuilder/AddSmartViewModalController'
|
||||||
|
import EditSmartViewModal from './EditSmartViewModal'
|
||||||
|
import SmartViewItem from './SmartViewItem'
|
||||||
|
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||||
|
import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscriptionBanner'
|
||||||
|
|
||||||
|
type NewType = {
|
||||||
|
application: WebApplication
|
||||||
|
navigationController: NavigationController
|
||||||
|
featuresController: FeaturesController
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NewType
|
||||||
|
|
||||||
|
const SmartViews = ({ application, navigationController, featuresController }: Props) => {
|
||||||
|
const [editingSmartView, setEditingSmartView] = useState<SmartView | undefined>(undefined)
|
||||||
|
|
||||||
|
const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application])
|
||||||
|
|
||||||
|
const nonSystemSmartViews = navigationController.smartViews.filter((view) => !isSystemView(view))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PreferencesGroup>
|
||||||
|
<PreferencesSegment>
|
||||||
|
<Title>Smart Views</Title>
|
||||||
|
{!featuresController.hasSmartViews && (
|
||||||
|
<NoSubscriptionBanner
|
||||||
|
className="mt-2"
|
||||||
|
application={application}
|
||||||
|
title="Upgrade for smart views"
|
||||||
|
message="Create smart views to organize your notes according to conditions you define."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{featuresController.hasSmartViews && (
|
||||||
|
<>
|
||||||
|
<div className="my-2 flex flex-col">
|
||||||
|
{nonSystemSmartViews.map((view) => (
|
||||||
|
<SmartViewItem
|
||||||
|
key={view.uuid}
|
||||||
|
view={view}
|
||||||
|
onEdit={() => setEditingSmartView(view)}
|
||||||
|
onDelete={() => navigationController.remove(view, true)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
addSmartViewModalController.setIsAddingSmartView(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Smart View
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PreferencesSegment>
|
||||||
|
</PreferencesGroup>
|
||||||
|
{!!editingSmartView && (
|
||||||
|
<EditSmartViewModal
|
||||||
|
application={application}
|
||||||
|
navigationController={navigationController}
|
||||||
|
view={editingSmartView}
|
||||||
|
closeDialog={() => {
|
||||||
|
setEditingSmartView(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{addSmartViewModalController.isAddingSmartView && (
|
||||||
|
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(SmartViews)
|
||||||
@@ -7,7 +7,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ModalDialogDescription: FunctionComponent<Props> = ({ children, className = '' }) => (
|
const ModalDialogDescription: FunctionComponent<Props> = ({ children, className = '' }) => (
|
||||||
<AlertDialogDescription className={`overflow-y-scroll px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
|
<AlertDialogDescription className={`overflow-y-auto px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default ModalDialogDescription
|
export default ModalDialogDescription
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import CompoundPredicateBuilder from '@/Components/SmartViewBuilder/CompoundPredicateBuilder'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import IconPicker from '@/Components/Icon/IconPicker'
|
||||||
|
import Popover from '@/Components/Popover/Popover'
|
||||||
|
import ModalDialog from '@/Components/Shared/ModalDialog'
|
||||||
|
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
|
||||||
|
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
|
||||||
|
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
|
||||||
|
import Spinner from '@/Components/Spinner/Spinner'
|
||||||
|
import { Platform } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { AddSmartViewModalController } from './AddSmartViewModalController'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
controller: AddSmartViewModalController
|
||||||
|
platform: Platform
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddSmartViewModal = ({ controller, platform }: Props) => {
|
||||||
|
const { isSaving, title, setTitle, icon, setIcon, closeModal, saveCurrentSmartView, predicateController } = controller
|
||||||
|
|
||||||
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
|
||||||
|
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const toggleIconPicker = () => {
|
||||||
|
setShouldShowIconPicker((shouldShow) => !shouldShow)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!title.length) {
|
||||||
|
titleInputRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveCurrentSmartView()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={closeModal}>Add Smart View</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="text-sm font-semibold">Title:</div>
|
||||||
|
<input
|
||||||
|
className="rounded border border-border bg-default py-1 px-2"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value)
|
||||||
|
}}
|
||||||
|
ref={titleInputRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="text-sm font-semibold">Icon:</div>
|
||||||
|
<button
|
||||||
|
className="rounded border border-border p-2"
|
||||||
|
aria-label="Change icon"
|
||||||
|
onClick={toggleIconPicker}
|
||||||
|
ref={iconPickerButtonRef}
|
||||||
|
>
|
||||||
|
<Icon type={icon || 'restore'} />
|
||||||
|
</button>
|
||||||
|
<Popover
|
||||||
|
open={shouldShowIconPicker}
|
||||||
|
anchorElement={iconPickerButtonRef.current}
|
||||||
|
togglePopover={toggleIconPicker}
|
||||||
|
align="start"
|
||||||
|
overrideZIndex="z-modal"
|
||||||
|
>
|
||||||
|
<div className="p-2">
|
||||||
|
<IconPicker
|
||||||
|
selectedValue={icon || 'restore'}
|
||||||
|
onIconChange={(value?: string | undefined) => {
|
||||||
|
setIcon(value ?? 'restore')
|
||||||
|
toggleIconPicker()
|
||||||
|
}}
|
||||||
|
platform={platform}
|
||||||
|
useIconGrid={true}
|
||||||
|
portalDropdown={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="text-sm font-semibold">Predicate:</div>
|
||||||
|
<CompoundPredicateBuilder controller={predicateController} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons>
|
||||||
|
<Button disabled={isSaving} onClick={save}>
|
||||||
|
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isSaving} onClick={closeModal}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(AddSmartViewModal)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { CompoundPredicateBuilderController } from '@/Components/SmartViewBuilder/CompoundPredicateBuilderController'
|
||||||
|
import { predicateFromJson } from '@standardnotes/snjs'
|
||||||
|
import { action, makeObservable, observable } from 'mobx'
|
||||||
|
|
||||||
|
export class AddSmartViewModalController {
|
||||||
|
isAddingSmartView = false
|
||||||
|
isSaving = false
|
||||||
|
|
||||||
|
title = ''
|
||||||
|
|
||||||
|
icon = 'restore'
|
||||||
|
|
||||||
|
predicateController = new CompoundPredicateBuilderController()
|
||||||
|
|
||||||
|
constructor(private application: WebApplication) {
|
||||||
|
makeObservable(this, {
|
||||||
|
isAddingSmartView: observable,
|
||||||
|
setIsAddingSmartView: action,
|
||||||
|
|
||||||
|
isSaving: observable,
|
||||||
|
setIsSaving: action,
|
||||||
|
|
||||||
|
title: observable,
|
||||||
|
setTitle: action,
|
||||||
|
|
||||||
|
icon: observable,
|
||||||
|
setIcon: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAddingSmartView = (isAddingSmartView: boolean) => {
|
||||||
|
this.isAddingSmartView = isAddingSmartView
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving = (isSaving: boolean) => {
|
||||||
|
this.isSaving = isSaving
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle = (title: string) => {
|
||||||
|
this.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
setIcon = (icon: string) => {
|
||||||
|
this.icon = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal = () => {
|
||||||
|
this.setIsAddingSmartView(false)
|
||||||
|
this.setTitle('')
|
||||||
|
this.setIcon('')
|
||||||
|
this.setIsSaving(false)
|
||||||
|
this.predicateController.resetState()
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentSmartView = async () => {
|
||||||
|
this.setIsSaving(true)
|
||||||
|
|
||||||
|
if (!this.title) {
|
||||||
|
this.setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const predicate = predicateFromJson(this.predicateController.toJson())
|
||||||
|
await this.application.items.createSmartView(this.title, predicate, this.icon)
|
||||||
|
|
||||||
|
this.setIsSaving(false)
|
||||||
|
this.closeModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { AllNonCompoundPredicateOperators, PredicateCompoundOperator, PredicateOperator } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import { CompoundPredicateBuilderController } from './CompoundPredicateBuilderController'
|
||||||
|
import { PredicateKeypath, PredicateKeypathLabels, PredicateKeypathTypes } from './PredicateKeypaths'
|
||||||
|
import PredicateValue from './PredicateValue'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
controller: CompoundPredicateBuilderController
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompoundPredicateBuilder = ({ controller }: Props) => {
|
||||||
|
const { operator, setOperator, predicates, setPredicate, changePredicateKeypath, addPredicate, removePredicate } =
|
||||||
|
controller
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="predicate"
|
||||||
|
value="and"
|
||||||
|
checked={operator === 'and'}
|
||||||
|
onChange={(event) => {
|
||||||
|
setOperator(event.target.value as PredicateCompoundOperator)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Should match ALL conditions
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="predicate"
|
||||||
|
value="or"
|
||||||
|
checked={operator === 'or'}
|
||||||
|
onChange={(event) => {
|
||||||
|
setOperator(event.target.value as PredicateCompoundOperator)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Should match ANY conditions
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{predicates.map((predicate, index) => (
|
||||||
|
<div className="flex flex-col gap-2.5" key={index}>
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
{index !== 0 && <div className="mr-2 text-sm font-semibold">{operator === 'and' ? 'AND' : 'OR'}</div>}
|
||||||
|
<select
|
||||||
|
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
|
||||||
|
value={predicate.keypath}
|
||||||
|
onChange={(event) => {
|
||||||
|
changePredicateKeypath(index, event.target.value as PredicateKeypath)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(PredicateKeypathLabels).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
|
||||||
|
value={predicate.operator}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPredicate(index, { operator: event.target.value as PredicateOperator })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{AllNonCompoundPredicateOperators.map((operator) => (
|
||||||
|
<option key={operator} value={operator}>
|
||||||
|
{operator}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{predicate.keypath && (
|
||||||
|
<PredicateValue
|
||||||
|
keypath={predicate.keypath as PredicateKeypath}
|
||||||
|
value={predicate.value.toString()}
|
||||||
|
setValue={(value: string) => {
|
||||||
|
setPredicate(index, { value })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{index !== 0 && (
|
||||||
|
<button
|
||||||
|
className="rounded border border-border p-1 text-danger"
|
||||||
|
aria-label="Remove condition"
|
||||||
|
onClick={() => {
|
||||||
|
removePredicate(index)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="trash" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{index === predicates.length - 1 && (
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
addPredicate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add another condition
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{predicates.some((predicate) => PredicateKeypathTypes[predicate.keypath as PredicateKeypath] === 'date') && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-md border-2 border-info-backdrop bg-info-backdrop py-3 px-4 [&_code]:rounded [&_code]:bg-default [&_code]:px-1.5 [&_code]:py-1">
|
||||||
|
<div className="text-sm font-semibold">Date Examples:</div>
|
||||||
|
<ul className="space-y-2 pl-4">
|
||||||
|
<li>
|
||||||
|
To get all the items modified within the last 7 days, you can use <code>User Modified Date</code>{' '}
|
||||||
|
<code>></code> <code>7.days.ago</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To get all the items created before June 2022, you can use <code>Created At</code> <code><</code>{' '}
|
||||||
|
<code>06/01/2022</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(CompoundPredicateBuilder)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { PlainEditorType } from '@/Utils/DropdownItemsForEditors'
|
||||||
|
import { NoteType, PredicateCompoundOperator, PredicateJsonForm } from '@standardnotes/snjs'
|
||||||
|
import { makeObservable, observable, action } from 'mobx'
|
||||||
|
import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths'
|
||||||
|
|
||||||
|
const getEmptyPredicate = (): PredicateJsonForm => {
|
||||||
|
return {
|
||||||
|
keypath: 'title',
|
||||||
|
operator: '!=',
|
||||||
|
value: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompoundPredicateBuilderController {
|
||||||
|
operator: PredicateCompoundOperator = 'and'
|
||||||
|
predicates: PredicateJsonForm[] = [getEmptyPredicate()]
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeObservable(this, {
|
||||||
|
operator: observable,
|
||||||
|
setOperator: action,
|
||||||
|
|
||||||
|
predicates: observable,
|
||||||
|
setPredicate: action,
|
||||||
|
addPredicate: action,
|
||||||
|
removePredicate: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setOperator = (operator: PredicateCompoundOperator) => {
|
||||||
|
this.operator = operator
|
||||||
|
}
|
||||||
|
|
||||||
|
setPredicate = (index: number, predicate: Partial<PredicateJsonForm>) => {
|
||||||
|
const predicateAtIndex = this.predicates[index]
|
||||||
|
this.predicates[index] = {
|
||||||
|
...predicateAtIndex,
|
||||||
|
...predicate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changePredicateKeypath = (index: number, keypath: string) => {
|
||||||
|
const currentKeyPath = this.predicates[index].keypath as PredicateKeypath
|
||||||
|
const currentKeyPathType = PredicateKeypathTypes[currentKeyPath]
|
||||||
|
const newKeyPathType = PredicateKeypathTypes[keypath as PredicateKeypath]
|
||||||
|
|
||||||
|
if (currentKeyPathType !== newKeyPathType) {
|
||||||
|
switch (newKeyPathType) {
|
||||||
|
case 'string':
|
||||||
|
this.setPredicate(index, { value: '' })
|
||||||
|
break
|
||||||
|
case 'boolean':
|
||||||
|
this.setPredicate(index, { value: true })
|
||||||
|
break
|
||||||
|
case 'number':
|
||||||
|
this.setPredicate(index, { value: 0 })
|
||||||
|
break
|
||||||
|
case 'noteType':
|
||||||
|
this.setPredicate(index, { value: Object.values(NoteType)[0] })
|
||||||
|
break
|
||||||
|
case 'editorIdentifier':
|
||||||
|
this.setPredicate(index, { value: PlainEditorType })
|
||||||
|
break
|
||||||
|
case 'date':
|
||||||
|
this.setPredicate(index, { value: '1.days.ago' })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setPredicate(index, { keypath })
|
||||||
|
}
|
||||||
|
|
||||||
|
addPredicate = () => {
|
||||||
|
this.predicates.push(getEmptyPredicate())
|
||||||
|
}
|
||||||
|
|
||||||
|
removePredicate = (index: number) => {
|
||||||
|
this.predicates.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): PredicateJsonForm {
|
||||||
|
return {
|
||||||
|
operator: this.operator,
|
||||||
|
value: this.predicates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetState() {
|
||||||
|
this.operator = 'and'
|
||||||
|
this.predicates = [getEmptyPredicate()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export type PredicateKeypath =
|
||||||
|
| 'title'
|
||||||
|
| 'title.length'
|
||||||
|
| 'text'
|
||||||
|
| 'text.length'
|
||||||
|
| 'noteType'
|
||||||
|
| 'authorizedForListed'
|
||||||
|
| 'editorIdentifier'
|
||||||
|
| 'userModifiedDate'
|
||||||
|
| 'serverUpdatedAt'
|
||||||
|
| 'created_at'
|
||||||
|
| 'conflict_of'
|
||||||
|
| 'protected'
|
||||||
|
| 'trashed'
|
||||||
|
| 'pinned'
|
||||||
|
| 'archived'
|
||||||
|
| 'locked'
|
||||||
|
| 'starred'
|
||||||
|
| 'hidePreview'
|
||||||
|
| 'spellcheck'
|
||||||
|
|
||||||
|
export const PredicateKeypathLabels: { [k in PredicateKeypath]: string } = {
|
||||||
|
title: 'Title',
|
||||||
|
'title.length': 'Title Length',
|
||||||
|
text: 'Text',
|
||||||
|
'text.length': 'Text Length',
|
||||||
|
noteType: 'Note Type',
|
||||||
|
authorizedForListed: 'Authorized For Listed',
|
||||||
|
editorIdentifier: 'Editor Identifier',
|
||||||
|
userModifiedDate: 'User Modified Date',
|
||||||
|
serverUpdatedAt: 'Server Updated At',
|
||||||
|
created_at: 'Created At',
|
||||||
|
conflict_of: 'Conflict Of',
|
||||||
|
protected: 'Protected',
|
||||||
|
trashed: 'Trashed',
|
||||||
|
pinned: 'Pinned',
|
||||||
|
archived: 'Archived',
|
||||||
|
locked: 'Locked',
|
||||||
|
starred: 'Starred',
|
||||||
|
hidePreview: 'Hide Preview',
|
||||||
|
spellcheck: 'Spellcheck',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PredicateKeypathTypes: {
|
||||||
|
[k in PredicateKeypath]: 'string' | 'noteType' | 'editorIdentifier' | 'number' | 'boolean' | 'date'
|
||||||
|
} = {
|
||||||
|
title: 'string',
|
||||||
|
'title.length': 'number',
|
||||||
|
text: 'string',
|
||||||
|
'text.length': 'number',
|
||||||
|
noteType: 'noteType',
|
||||||
|
authorizedForListed: 'boolean',
|
||||||
|
editorIdentifier: 'editorIdentifier',
|
||||||
|
userModifiedDate: 'date',
|
||||||
|
serverUpdatedAt: 'date',
|
||||||
|
created_at: 'date',
|
||||||
|
conflict_of: 'string',
|
||||||
|
protected: 'boolean',
|
||||||
|
trashed: 'boolean',
|
||||||
|
pinned: 'boolean',
|
||||||
|
archived: 'boolean',
|
||||||
|
locked: 'boolean',
|
||||||
|
starred: 'boolean',
|
||||||
|
hidePreview: 'boolean',
|
||||||
|
spellcheck: 'boolean',
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { getDropdownItemsForAllEditors } from '@/Utils/DropdownItemsForEditors'
|
||||||
|
import { NoteType } from '@standardnotes/snjs'
|
||||||
|
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||||
|
import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
keypath: PredicateKeypath
|
||||||
|
value: string
|
||||||
|
setValue: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PredicateValue = ({ keypath, value, setValue }: Props) => {
|
||||||
|
const application = useApplication()
|
||||||
|
const type = PredicateKeypathTypes[keypath]
|
||||||
|
const editorItems = getDropdownItemsForAllEditors(application)
|
||||||
|
|
||||||
|
return type === 'noteType' ? (
|
||||||
|
<select
|
||||||
|
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(NoteType).map(([key, value]) => (
|
||||||
|
<option key={key} value={value}>
|
||||||
|
{key}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : type === 'editorIdentifier' ? (
|
||||||
|
<select
|
||||||
|
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editorItems.map((editor) => (
|
||||||
|
<option key={editor.value} value={editor.value}>
|
||||||
|
{editor.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : type === 'string' || type === 'date' ? (
|
||||||
|
<input
|
||||||
|
className="flex-grow rounded border border-border bg-default py-1.5 px-2"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : type === 'boolean' ? (
|
||||||
|
<select
|
||||||
|
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="true">True</option>
|
||||||
|
<option value="false">False</option>
|
||||||
|
</select>
|
||||||
|
) : type === 'number' ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="flex-grow rounded border border-border bg-default py-1.5 px-2"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PredicateValue
|
||||||
@@ -57,7 +57,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'fixed bottom-0 flex min-h-[50px] w-full w-full items-center border-t border-border bg-contrast',
|
'fixed bottom-0 flex min-h-[50px] w-full items-center border-t border-border bg-contrast',
|
||||||
'px-3.5 pb-safe-bottom pt-2.5 md:hidden',
|
'px-3.5 pb-safe-bottom pt-2.5 md:hidden',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -135,14 +135,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
|
|||||||
'md:hover:[overflow-y:_overlay]',
|
'md:hover:[overflow-y:_overlay]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={'section-title-bar'}>
|
<SmartViewsSection application={application} viewControllerManager={viewControllerManager} />
|
||||||
<div className="section-title-bar-header">
|
|
||||||
<div className="title text-base md:text-sm">
|
|
||||||
<span className="font-bold">Views</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SmartViewsSection viewControllerManager={viewControllerManager} />
|
|
||||||
<TagsSection viewControllerManager={viewControllerManager} />
|
<TagsSection viewControllerManager={viewControllerManager} />
|
||||||
</div>
|
</div>
|
||||||
{NavigationFooter}
|
{NavigationFooter}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||||
|
import { SmartView } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'react'
|
import { FunctionComponent } from 'react'
|
||||||
import SmartViewsListItem from './SmartViewsListItem'
|
import SmartViewsListItem from './SmartViewsListItem'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
viewControllerManager: ViewControllerManager
|
viewControllerManager: ViewControllerManager
|
||||||
|
setEditingSmartView: (smartView: SmartView) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) => {
|
const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager, setEditingSmartView }: Props) => {
|
||||||
const allViews = viewControllerManager.navigationController.smartViews
|
const allViews = viewControllerManager.navigationController.smartViews
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +21,7 @@ const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager }: Pro
|
|||||||
view={view}
|
view={view}
|
||||||
tagsState={viewControllerManager.navigationController}
|
tagsState={viewControllerManager.navigationController}
|
||||||
features={viewControllerManager.featuresController}
|
features={viewControllerManager.featuresController}
|
||||||
|
setEditingSmartView={setEditingSmartView}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Props = {
|
|||||||
view: SmartView
|
view: SmartView
|
||||||
tagsState: NavigationController
|
tagsState: NavigationController
|
||||||
features: FeaturesController
|
features: FeaturesController
|
||||||
|
setEditingSmartView: (smartView: SmartView) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING_BASE_PX = 14
|
const PADDING_BASE_PX = 14
|
||||||
@@ -35,7 +36,7 @@ const getIconClass = (view: SmartView, isSelected: boolean): string => {
|
|||||||
return mapping[view.uuid as SystemViewId] || (isSelected ? 'text-info' : 'text-neutral')
|
return mapping[view.uuid as SystemViewId] || (isSelected ? 'text-info' : 'text-neutral')
|
||||||
}
|
}
|
||||||
|
|
||||||
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEditingSmartView }) => {
|
||||||
const { toggleAppPane } = useResponsiveAppPane()
|
const { toggleAppPane } = useResponsiveAppPane()
|
||||||
|
|
||||||
const [title, setTitle] = useState(view.title || '')
|
const [title, setTitle] = useState(view.title || '')
|
||||||
@@ -85,13 +86,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
|||||||
}
|
}
|
||||||
}, [inputRef, isEditing])
|
}, [inputRef, isEditing])
|
||||||
|
|
||||||
const onClickRename = useCallback(() => {
|
const onClickEdit = useCallback(() => {
|
||||||
tagsState.setEditingTag(view)
|
setEditingSmartView(view)
|
||||||
}, [tagsState, view])
|
}, [setEditingSmartView, view])
|
||||||
|
|
||||||
const onClickSave = useCallback(() => {
|
|
||||||
inputRef.current?.blur()
|
|
||||||
}, [inputRef])
|
|
||||||
|
|
||||||
const onClickDelete = useCallback(() => {
|
const onClickDelete = useCallback(() => {
|
||||||
tagsState.remove(view, true).catch(console.error)
|
tagsState.remove(view, true).catch(console.error)
|
||||||
@@ -107,6 +104,11 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
|||||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
className={classNames('tag px-3.5', isSelected && 'selected', isFaded && 'opacity-50')}
|
className={classNames('tag px-3.5', isSelected && 'selected', isFaded && 'opacity-50')}
|
||||||
onClick={selectCurrentTag}
|
onClick={selectCurrentTag}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onClickEdit()
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
||||||
}}
|
}}
|
||||||
@@ -147,16 +149,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
|||||||
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="menu">
|
<div className="menu">
|
||||||
{!isEditing && (
|
<a className="item" onClick={onClickEdit}>
|
||||||
<a className="item" onClick={onClickRename}>
|
Edit
|
||||||
Rename
|
</a>
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{isEditing && (
|
|
||||||
<a className="item" onClick={onClickSave}>
|
|
||||||
Save
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<a className="item" onClick={onClickDelete}>
|
<a className="item" onClick={onClickDelete}>
|
||||||
Delete
|
Delete
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,16 +1,66 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { SMART_TAGS_FEATURE_NAME } from '@/Constants/Constants'
|
||||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||||
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
|
import { SmartView } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'react'
|
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
|
||||||
|
import IconButton from '../Button/IconButton'
|
||||||
|
import EditSmartViewModal from '../Preferences/Panes/General/SmartViews/EditSmartViewModal'
|
||||||
|
import AddSmartViewModal from '../SmartViewBuilder/AddSmartViewModal'
|
||||||
|
import { AddSmartViewModalController } from '../SmartViewBuilder/AddSmartViewModalController'
|
||||||
import SmartViewsList from './SmartViewsList'
|
import SmartViewsList from './SmartViewsList'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
viewControllerManager: ViewControllerManager
|
viewControllerManager: ViewControllerManager
|
||||||
}
|
}
|
||||||
|
|
||||||
const SmartViewsSection: FunctionComponent<Props> = ({ viewControllerManager }) => {
|
const SmartViewsSection: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
|
||||||
|
const premiumModal = usePremiumModal()
|
||||||
|
const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application])
|
||||||
|
|
||||||
|
const [editingSmartView, setEditingSmartView] = useState<SmartView | undefined>(undefined)
|
||||||
|
|
||||||
|
const createNewSmartView = useCallback(() => {
|
||||||
|
if (!viewControllerManager.featuresController.hasSmartViews) {
|
||||||
|
premiumModal.activate(SMART_TAGS_FEATURE_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addSmartViewModalController.setIsAddingSmartView(true)
|
||||||
|
}, [addSmartViewModalController, premiumModal, viewControllerManager.featuresController.hasSmartViews])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<SmartViewsList viewControllerManager={viewControllerManager} />
|
<div className={'section-title-bar'}>
|
||||||
|
<div className="section-title-bar-header">
|
||||||
|
<div className="title text-base md:text-sm">
|
||||||
|
<span className="font-bold">Views</span>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
focusable={true}
|
||||||
|
icon="add"
|
||||||
|
title="Create a new smart view"
|
||||||
|
className="p-0 text-neutral"
|
||||||
|
onClick={createNewSmartView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SmartViewsList viewControllerManager={viewControllerManager} setEditingSmartView={setEditingSmartView} />
|
||||||
|
{!!editingSmartView && (
|
||||||
|
<EditSmartViewModal
|
||||||
|
application={application}
|
||||||
|
navigationController={viewControllerManager.navigationController}
|
||||||
|
view={editingSmartView}
|
||||||
|
closeDialog={() => {
|
||||||
|
setEditingSmartView(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{addSmartViewModalController.isAddingSmartView && (
|
||||||
|
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
|
|||||||
selectedValue={selectedTag.iconString}
|
selectedValue={selectedTag.iconString}
|
||||||
platform={application.platform}
|
platform={application.platform}
|
||||||
className={'px-3 py-1.5'}
|
className={'px-3 py-1.5'}
|
||||||
|
useIconGrid={true}
|
||||||
|
iconGridClassName="max-h-30"
|
||||||
/>
|
/>
|
||||||
<HorizontalSeparator classes="my-2" />
|
<HorizontalSeparator classes="my-2" />
|
||||||
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickStar}>
|
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickStar}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ module.exports = {
|
|||||||
extend: {
|
extend: {
|
||||||
spacing: {
|
spacing: {
|
||||||
4.5: '1.125rem',
|
4.5: '1.125rem',
|
||||||
|
5.5: '1.325rem',
|
||||||
8.5: '2.125rem',
|
8.5: '2.125rem',
|
||||||
13: '3.25rem',
|
13: '3.25rem',
|
||||||
15: '3.75rem',
|
15: '3.75rem',
|
||||||
|
|||||||
Reference in New Issue
Block a user