feat: Moments: your personal photo journal, now available in Labs (#2079)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Ref, forwardRef, ReactNode, ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
type ButtonStyle = 'default' | 'contrast' | 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||
export type ButtonStyle = 'default' | 'contrast' | 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||
|
||||
const getColorsForNormalVariant = (style: ButtonStyle) => {
|
||||
switch (style) {
|
||||
@@ -21,7 +21,7 @@ const getColorsForNormalVariant = (style: ButtonStyle) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getColorsForPrimaryVariant = (style: ButtonStyle) => {
|
||||
export const getColorsForPrimaryVariant = (style: ButtonStyle) => {
|
||||
switch (style) {
|
||||
case 'default':
|
||||
return 'bg-default text-foreground'
|
||||
|
||||
@@ -365,8 +365,8 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
|
||||
<div className="flex flex-col pr-5">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="text-base font-semibold uppercase text-text lg:text-xs">Daily Notebook</div>
|
||||
<div className="ml-2 rounded bg-success px-1.5 py-[1px] text-[10px] font-bold text-success-contrast">
|
||||
Experimental
|
||||
<div className="ml-2 rounded bg-warning px-1.5 py-[1px] text-[10px] font-bold text-warning-contrast">
|
||||
Labs
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">Capture new notes daily with a calendar-based layout</div>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { doesItemMatchSearchQuery } from '@/Utils/Items/Search/doesItemMatchSearchQuery'
|
||||
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
|
||||
import { classNames, ContentType, DecryptedItem, naturalSort } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { ChangeEventHandler, FocusEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import LinkedItemMeta from '../LinkedItems/LinkedItemMeta'
|
||||
import Menu from '../Menu/Menu'
|
||||
|
||||
type Props = {
|
||||
contentTypes: ContentType[]
|
||||
placeholder: string
|
||||
onSelection: (item: DecryptedItem) => void
|
||||
}
|
||||
|
||||
const ItemSelectionDropdown = ({ contentTypes, placeholder, onSelection }: Props) => {
|
||||
const application = useApplication()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false)
|
||||
|
||||
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const searchResultsMenuRef = useRef<HTMLMenuElement>(null)
|
||||
const [items, setItems] = useState<DecryptedItem[]>([])
|
||||
|
||||
const showDropdown = () => {
|
||||
const { clientHeight } = document.documentElement
|
||||
const inputRect = inputRef.current?.getBoundingClientRect()
|
||||
if (inputRect) {
|
||||
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2)
|
||||
setDropdownVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
|
||||
setDropdownVisible(visible)
|
||||
setSearchQuery('')
|
||||
})
|
||||
|
||||
const onBlur: FocusEventHandler = (event) => {
|
||||
closeOnBlur(event)
|
||||
}
|
||||
|
||||
const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
setSearchQuery(event.currentTarget.value)
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
showDropdown()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const searchableItems = naturalSort(application.items.getItems(contentTypes), 'title')
|
||||
const filteredItems = searchableItems.filter((item) => {
|
||||
return doesItemMatchSearchQuery(item, searchQuery, application)
|
||||
})
|
||||
setItems(filteredItems)
|
||||
}, [searchQuery, application, contentTypes])
|
||||
|
||||
const onSelectItem = useCallback(
|
||||
(item: DecryptedItem) => {
|
||||
onSelection(item)
|
||||
setSearchQuery('')
|
||||
setDropdownVisible(false)
|
||||
},
|
||||
[onSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<Disclosure open={dropdownVisible} onChange={showDropdown}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={classNames(
|
||||
'mr-10 w-70',
|
||||
'bg-transparent text-sm text-text focus:border-b-2 focus:border-solid focus:border-info lg:text-xs',
|
||||
'no-border h-7 focus:shadow-none focus:outline-none',
|
||||
)}
|
||||
value={searchQuery}
|
||||
onChange={onSearchQueryChange}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onBlur}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{dropdownVisible && (
|
||||
<DisclosurePanel
|
||||
className={classNames(
|
||||
'mr-10 w-70',
|
||||
'absolute z-dropdown-menu flex flex-col overflow-y-auto rounded bg-default py-2 shadow-main',
|
||||
)}
|
||||
style={{
|
||||
maxHeight: dropdownMaxHeight,
|
||||
}}
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Menu
|
||||
isOpen={dropdownVisible}
|
||||
a11yLabel="Tag search results"
|
||||
ref={searchResultsMenuRef}
|
||||
shouldAutoFocus={false}
|
||||
>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<button
|
||||
key={item.uuid}
|
||||
className={classNames(
|
||||
'flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast',
|
||||
'hover:text-foreground focus:bg-info-backdrop',
|
||||
)}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
<LinkedItemMeta item={item} searchQuery={searchQuery} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
</DisclosurePanel>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ItemSelectionDropdown)
|
||||
@@ -13,7 +13,7 @@ import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag
|
||||
|
||||
type Props = {
|
||||
link: ItemLink
|
||||
activateItem: (item: LinkableItem) => Promise<void>
|
||||
activateItem?: (item: LinkableItem) => Promise<void>
|
||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||
focusPreviousItem?: () => void
|
||||
focusNextItem?: () => void
|
||||
@@ -59,7 +59,7 @@ const LinkedItemBubble = ({
|
||||
const onClick: MouseEventHandler = (event) => {
|
||||
if (wasClicked && event.target !== unlinkButtonRef.current) {
|
||||
setWasClicked(false)
|
||||
void activateItem(link.item)
|
||||
void activateItem?.(link.item)
|
||||
} else {
|
||||
setWasClicked(true)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSe
|
||||
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
||||
import Persistence from './Persistence'
|
||||
import SmartViews from './SmartViews/SmartViews'
|
||||
import Moments from './Moments'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
@@ -24,6 +25,7 @@ const General: FunctionComponent<Props> = ({ viewControllerManager, application,
|
||||
<Tools application={application} />
|
||||
<SmartViews application={application} featuresController={viewControllerManager.featuresController} />
|
||||
<LabsPane application={application} />
|
||||
<Moments application={application} />
|
||||
<Advanced
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Pill, Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
||||
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import ItemSelectionDropdown from '@/Components/ItemSelectionDropdown/ItemSelectionDropdown'
|
||||
import { ContentType, DecryptedItem, PrefKey, SNTag } from '@standardnotes/snjs'
|
||||
import usePreference from '@/Hooks/usePreference'
|
||||
import LinkedItemBubble from '@/Components/LinkedItems/LinkedItemBubble'
|
||||
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
const Moments: FunctionComponent<Props> = ({ application }: Props) => {
|
||||
const momentsEnabled = application.momentsService.isEnabled
|
||||
const premiumModal = usePremiumModal()
|
||||
|
||||
const defaultTagId = usePreference<string>(PrefKey.MomentsDefaultTagUuid)
|
||||
const [defaultTag, setDefaultTag] = useState<SNTag | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultTagId) {
|
||||
setDefaultTag(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const tag = application.items.findItem(defaultTagId) as SNTag | undefined
|
||||
setDefaultTag(tag)
|
||||
}, [defaultTagId, application])
|
||||
|
||||
const enable = useCallback(() => {
|
||||
if (!application.featuresController.entitledToFiles) {
|
||||
premiumModal.activate('Moments')
|
||||
return
|
||||
}
|
||||
void application.momentsService.enableMoments()
|
||||
}, [application, premiumModal])
|
||||
|
||||
const disable = useCallback(() => {
|
||||
void application.momentsService.disableMoments()
|
||||
}, [application])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (momentsEnabled) {
|
||||
disable()
|
||||
} else {
|
||||
enable()
|
||||
}
|
||||
}, [momentsEnabled, enable, disable])
|
||||
|
||||
const takePhoto = useCallback(() => {
|
||||
if (!application.featuresController.entitledToFiles) {
|
||||
premiumModal.activate('Moments')
|
||||
return
|
||||
}
|
||||
|
||||
void application.momentsService.takePhoto()
|
||||
}, [application, premiumModal])
|
||||
|
||||
const selectTag = useCallback(
|
||||
(tag: DecryptedItem) => {
|
||||
void application.setPreference(PrefKey.MomentsDefaultTagUuid, tag.uuid)
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const unselectTag = useCallback(async () => {
|
||||
void application.setPreference(PrefKey.MomentsDefaultTagUuid, undefined)
|
||||
}, [application])
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start">
|
||||
<Title>Moments</Title>
|
||||
<Pill style={'warning'}>Labs</Pill>
|
||||
<Pill style={'info'}>Professional</Pill>
|
||||
</div>
|
||||
<Switch onChange={toggle} checked={momentsEnabled} />
|
||||
</div>
|
||||
|
||||
<Subtitle>Your personal photo journal</Subtitle>
|
||||
|
||||
{momentsEnabled && (
|
||||
<div className="mb-2 flex items-center">
|
||||
{defaultTag && (
|
||||
<div>
|
||||
<LinkedItemBubble
|
||||
className="m-1 mr-2"
|
||||
link={createLinkFromItem(defaultTag, 'linked')}
|
||||
unlinkItem={unselectTag}
|
||||
isBidirectional={false}
|
||||
inlineFlex={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ItemSelectionDropdown
|
||||
onSelection={selectTag}
|
||||
placeholder="Select tag to save Moments to..."
|
||||
contentTypes={[ContentType.Tag]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col"></div>
|
||||
<PreferencesSegment>
|
||||
<Text>
|
||||
Introducing Moments, a new feature in Standard Notes that lets you capture candid photos of yourself
|
||||
throughout the day, right in the app. With Moments, you can create a visual record of your life, one photo
|
||||
at a time.
|
||||
</Text>
|
||||
|
||||
<Text className="mt-3">
|
||||
Moments uses your webcam or mobile selfie-cam to take a photo of you every half hour, ensuring that you
|
||||
have a complete record of your day. And because all photos are end-to-end encrypted and stored in your
|
||||
private account, you can trust that your memories are safe and secure.
|
||||
</Text>
|
||||
|
||||
<Text className="mt-3">
|
||||
Whether you're working at your computer or capturing notes on the go from your mobile device, Moments is a
|
||||
fun and easy way to document your life. Plus, with customizable photo intervals coming soon, you'll be
|
||||
able to tailor Moments to your unique needs. Enable Moments on a per-device basis to get started.
|
||||
</Text>
|
||||
<div className="mt-5 flex flex-row flex-wrap gap-3">
|
||||
<Button colorStyle="info" onClick={takePhoto}>
|
||||
Capture Present Moment
|
||||
</Button>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Moments)
|
||||
@@ -7,7 +7,6 @@ import { FunctionComponent, useState } from 'react'
|
||||
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
||||
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -28,7 +27,6 @@ const Tools: FunctionComponent<Props> = ({ application }: Props) => {
|
||||
<PreferencesSegment>
|
||||
<Title>Tools</Title>
|
||||
<div>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Show note saving status while editing</Subtitle>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ButtonStyle, getColorsForPrimaryVariant } from '@/Components/Button/Button'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { FunctionComponent, MouseEventHandler, ReactNode } from 'react'
|
||||
|
||||
@@ -37,3 +38,14 @@ export const LinkButton: FunctionComponent<{
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
|
||||
type PillProps = Props & {
|
||||
style: ButtonStyle
|
||||
}
|
||||
|
||||
export const Pill: FunctionComponent<PillProps> = ({ children, className, style }) => {
|
||||
const colorClass = getColorsForPrimaryVariant(style)
|
||||
return (
|
||||
<div className={classNames('ml-2 rounded px-2 py-1 text-[10px] font-bold', className, colorClass)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user