mirror of
https://github.com/standardnotes/app
synced 2026-09-14 12:45:58 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65abb9a49 | ||
|
|
68991abba7 | ||
|
|
7732f55a28 | ||
|
|
97dedc8523 | ||
|
|
f656185c16 | ||
|
|
1c23bc1747 | ||
|
|
eba8dbc38c | ||
|
|
f42657fa9e | ||
|
|
2b76d48392 | ||
|
|
5777e511a9 |
@@ -3,7 +3,7 @@
|
||||
Standard Notes is an end-to-end encrypted note-taking app for digitalists and professionals. Capture your notes, files, and life’s work all in one secure place.
|
||||
|
||||
[](https://github.com/standardnotes/app/releases)
|
||||
[](https://standardnotes.com/slack)
|
||||
[](https://standardnotes.com/discord)
|
||||
[](https://twitter.com/standardnotes)
|
||||
|
||||
### Why Standard Notes?
|
||||
@@ -38,7 +38,6 @@ Join us on
|
||||
- [Discord](https://standardnotes.com/discord)
|
||||
- [Twitter](https://twitter.com/StandardNotes)
|
||||
- [Forum](https://forum.standardnotes.org)
|
||||
- [Slack](https://standardnotes.com/slack)
|
||||
|
||||
Developers can create and publish their own extensions. Visit the [documentation hub](https://docs.standardnotes.com/) to learn more.
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.20.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
# [1.20.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api",
|
||||
"version": "1.20.0",
|
||||
"version": "1.20.1",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.23.297](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
## [3.23.296](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
## [3.23.295](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
## [3.23.294](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://standardnotes.com/slack)
|
||||
[](https://twitter.com/standardnotes)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,6 @@ const Urls = {
|
||||
Support: 'mailto:[email protected]',
|
||||
Website: 'https://standardnotes.com',
|
||||
GitHub: 'https://github.com/standardnotes',
|
||||
Slack: 'https://standardnotes.com/slack',
|
||||
Discord: 'https://standardnotes.com/discord',
|
||||
Twitter: 'https://twitter.com/StandardNotes',
|
||||
GitHubReleases: 'https://github.com/standardnotes/app/releases',
|
||||
@@ -604,12 +603,6 @@ function helpMenu(window: Electron.BrowserWindow, shell: Electron.Shell) {
|
||||
void shell.openExternal(Urls.Discord)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: str().slack,
|
||||
click() {
|
||||
void shell.openExternal(Urls.Slack)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: str().twitter,
|
||||
click() {
|
||||
|
||||
@@ -19,7 +19,6 @@ export function createEnglishStrings(): Strings {
|
||||
website: 'Website',
|
||||
gitHub: 'GitHub',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
twitter: 'Twitter',
|
||||
toggleErrorConsole: 'Toggle Error Console',
|
||||
openDataDirectory: 'Open Data Directory',
|
||||
|
||||
@@ -24,7 +24,6 @@ interface AppMenuStrings {
|
||||
website: string
|
||||
gitHub: string
|
||||
discord: string
|
||||
slack: string
|
||||
twitter: string
|
||||
toggleErrorConsole: string
|
||||
openDataDirectory: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@standardnotes/desktop",
|
||||
"main": "./app/dist/index.js",
|
||||
"version": "3.23.294",
|
||||
"version": "3.23.297",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Standard Notes.",
|
||||
"private": true,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [0.2.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/docs
|
||||
|
||||
## [0.2.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -17,7 +17,7 @@ Thank you for your interest in contributing to Standard Notes. We are very happy
|
||||
|
||||
1. Submit suggestions and feature ideas to our [community forum](https://forum.standardnotes.org). You can comment on the existing feature requests and subscribe to individual threads to follow their progress.
|
||||
2. Report bugs or issues to our [community forum](https://forum.standardnotes.org).
|
||||
3. Help others on the forum and our [Slack group](https://standardnotes.com/slack).
|
||||
3. Help others on the forum and our [Discord group](https://standardnotes.com/discord).
|
||||
4. Blog, tweet and share what you like about Standard Notes, what you use it for, and how it helps you.
|
||||
|
||||
We do things the old-fashioned way and rely on people like you telling other people like you about Standard Notes to sustain our efforts. Get a free month of Extended when your friend also joins Extended. Log into the [member dashboard](https://dashboard.standardnotes.com) to get your unique referral link. 🙂
|
||||
|
||||
@@ -138,6 +138,6 @@ There are an unlimited number of things you can build with components that do an
|
||||
|
||||
To see how we built [our own components](https://standardnotes.com/extensions), study the source code [available here](https://github.com/sn-extensions).
|
||||
|
||||
For questions on development, [post in the forum](https://forum.standardnotes.org) or [join our Slack](https://standardnotes.com/slack).
|
||||
For questions on development, [post in the forum](https://forum.standardnotes.org) or [join our Discord](https://standardnotes.com/discord).
|
||||
|
||||
If you'd like to support Standard Notes and use our secure hosting to install all the components we have to offer, consider purchasing the [Extended subscription](https://standardnotes.com/extended).
|
||||
|
||||
@@ -98,4 +98,4 @@ Standard Notes will ping the `latest_url`endpoint automatically to update extens
|
||||
|
||||
## Questions?
|
||||
|
||||
Join the [Slack group](https://standardnotes.com/slack) and ask away in the \#dev channel.
|
||||
Join the [Discord group](https://standardnotes.com/discord) and ask away in the \#dev channel.
|
||||
|
||||
@@ -155,8 +155,8 @@ For every received item:
|
||||
|
||||
## Next Steps
|
||||
|
||||
Join the [Slack group](https://standardnotes.com/slack) to discuss implementation details and ask any questions you may have.
|
||||
Join the [Discord group](https://standardnotes.com/discord) to discuss implementation details and ask any questions you may have.
|
||||
|
||||
You can also email [[email protected]](mailto:help@standardnotes.org).
|
||||
You can also email [[email protected]](mailto:help@standardnotes.com).
|
||||
|
||||
Follow [@standardnotes on Twitter](https://twitter.com/standardnotes) for updates and announcements.
|
||||
|
||||
@@ -338,8 +338,8 @@ result = ['004', nonce, ciphertext, encoded_authenticated_data].join(':')
|
||||
|
||||
## Next Steps
|
||||
|
||||
Join the [Slack group](https://standardnotes.com/slack) to discuss implementation details and ask any questions you may have.
|
||||
Join the [Discord group](https://standardnotes.com/Discord) to discuss implementation details and ask any questions you may have.
|
||||
|
||||
You can also email [[email protected]](mailto:help@standardnotes.org).
|
||||
You can also email [[email protected]](mailto:help@standardnotes.com).
|
||||
|
||||
Follow [@standardnotes on Twitter](https://twitter.com/standardnotes) for updates and announcements.
|
||||
|
||||
@@ -31,7 +31,6 @@ This documentation explains how to get started with using Standard Notes, publis
|
||||
- [GitHub](https://github.com/standardnotes)
|
||||
- [Discord](https://standardnotes.com/discord)
|
||||
- [Twitter](https://twitter.com/standardnotes)
|
||||
- [Slack](https://standardnotes.com/slack)
|
||||
- [Blog](https://blog.standardnotes.org)
|
||||
|
||||
## Something missing?
|
||||
|
||||
@@ -111,12 +111,12 @@ module.exports = {
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'Join our Slack',
|
||||
href: 'https://standardnotes.com/slack',
|
||||
label: 'Join our Discord',
|
||||
href: 'https://standardnotes.com/discord',
|
||||
},
|
||||
{
|
||||
label: 'Community Forum',
|
||||
href: 'https://forum.standardnotes.org',
|
||||
href: 'https://standardnotes.com/forum',
|
||||
},
|
||||
{
|
||||
label: 'Listed Blogging Platform',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/docs",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Standard Notes.",
|
||||
"private": true,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.19.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/encryption
|
||||
|
||||
## [1.19.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/encryption
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/encryption",
|
||||
"version": "1.19.8",
|
||||
"version": "1.19.9",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.25.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/filepicker
|
||||
|
||||
## [1.25.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/filepicker
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/filepicker",
|
||||
"version": "1.25.8",
|
||||
"version": "1.25.9",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.12.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/files
|
||||
|
||||
## [1.12.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/files
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/files",
|
||||
"version": "1.12.8",
|
||||
"version": "1.12.9",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.46.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/mobile
|
||||
|
||||
## [3.46.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/mobile
|
||||
|
||||
## [3.46.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/mobile
|
||||
|
||||
## [3.46.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **mobile:** android build ([5777e51](https://github.com/standardnotes/app/commit/5777e511a95fb5c3f6c867b239d849290a71fcea))
|
||||
|
||||
# [3.46.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
[](https://github.com/standardnotes/mobile/releases)
|
||||
[](https://github.com/standardnotes/mobile/blob/master/LICENSE)
|
||||
[](https://standardnotes.com/slack)
|
||||
[](https://twitter.com/standardnotes)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -149,6 +149,7 @@ android {
|
||||
|
||||
testBuildType System.getProperty('testBuildType', 'debug') // detox: This will later be used to control the test apk build type
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
missingDimensionStrategy 'store', 'play'
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/mobile",
|
||||
"version": "3.46.0",
|
||||
"version": "3.46.4",
|
||||
"author": "Standard Notes.",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.35.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
### Features
|
||||
|
||||
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
|
||||
|
||||
## [1.34.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/models
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/models",
|
||||
"version": "1.34.10",
|
||||
"version": "1.35.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -15,8 +15,7 @@ export interface PredicateJsonForm {
|
||||
export const AllPredicateCompoundOperators = ['and', 'or'] as const
|
||||
export type PredicateCompoundOperator = typeof AllPredicateCompoundOperators[number]
|
||||
|
||||
export const AllPredicateOperators = [
|
||||
...AllPredicateCompoundOperators,
|
||||
export const AllNonCompoundPredicateOperators = [
|
||||
'!=',
|
||||
'=',
|
||||
'<',
|
||||
@@ -30,6 +29,8 @@ export const AllPredicateOperators = [
|
||||
'includes',
|
||||
] as const
|
||||
|
||||
export const AllPredicateOperators = [...AllPredicateCompoundOperators, ...AllNonCompoundPredicateOperators] as const
|
||||
|
||||
export type PredicateOperator = typeof AllPredicateOperators[number]
|
||||
|
||||
export type SureValue = number | number[] | string[] | string | Date | boolean | false | ''
|
||||
|
||||
@@ -23,6 +23,10 @@ export function valueMatchesTargetValue(
|
||||
value = value.toLowerCase()
|
||||
}
|
||||
|
||||
if (value instanceof Date && typeof targetValue === 'string') {
|
||||
targetValue = new Date(targetValue)
|
||||
}
|
||||
|
||||
if (operator === 'not') {
|
||||
return !valueMatchesTargetValue(value, '=', targetValue)
|
||||
}
|
||||
|
||||
@@ -429,6 +429,36 @@ describe('predicates', () => {
|
||||
it('hours ago value', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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)
|
||||
} else if (unit === 'hours') {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.3.229](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.228](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.227](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.226](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.225](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/releases",
|
||||
"version": "1.3.225",
|
||||
"version": "1.3.229",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "dist/releases.json",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.44.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
### Features
|
||||
|
||||
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
|
||||
|
||||
# [1.43.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/services",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -148,4 +148,10 @@ export interface ItemsClientInterface {
|
||||
* @returns Whether the item is a template (unmanaged)
|
||||
*/
|
||||
isTemplateItem(item: DecryptedItemInterface): boolean
|
||||
|
||||
createSmartView<T extends DecryptedItemInterface<ItemContent>>(
|
||||
title: string,
|
||||
predicate: PredicateInterface<T>,
|
||||
iconString?: string,
|
||||
): Promise<SmartView>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [2.149.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
### Features
|
||||
|
||||
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
|
||||
|
||||
# [2.148.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1222,12 +1222,14 @@ export class ItemManager
|
||||
public async createSmartView<T extends Models.DecryptedItemInterface>(
|
||||
title: string,
|
||||
predicate: Models.PredicateInterface<T>,
|
||||
iconString?: string,
|
||||
): Promise<Models.SmartView> {
|
||||
return this.createItem(
|
||||
ContentType.SmartView,
|
||||
Models.FillItemContent({
|
||||
title,
|
||||
predicate: predicate.toJson(),
|
||||
iconString: iconString || 'restore',
|
||||
} as Models.SmartViewContent),
|
||||
true,
|
||||
) as Promise<Models.SmartView>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/snjs",
|
||||
"version": "2.148.0",
|
||||
"version": "2.149.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.13.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/ui-services
|
||||
|
||||
## [1.13.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/ui-services
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/ui-services",
|
||||
"version": "1.13.10",
|
||||
"version": "1.13.11",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [3.98.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
|
||||
|
||||
### Features
|
||||
|
||||
* add smart view with custom json ([#2000](https://github.com/standardnotes/app/issues/2000)) ([68991ab](https://github.com/standardnotes/app/commit/68991abba7a2b32047ec7cc6402c3a8b5cc6ec52))
|
||||
|
||||
# [3.97.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **mobile:** iOS IAP related adjustments ([1c23bc1](https://github.com/standardnotes/app/commit/1c23bc1747a6701b56c54abea7c6d0a6e373d4ae))
|
||||
|
||||
### Features
|
||||
|
||||
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
|
||||
|
||||
## [3.96.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show files option in linking menu even if not entitled ([f42657f](https://github.com/standardnotes/app/commit/f42657fa9e01b3b8a4ca555ea5c06236503b40a4))
|
||||
|
||||
# [3.96.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/web",
|
||||
"version": "3.96.0",
|
||||
"version": "3.98.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "dist/app.js",
|
||||
"author": "Standard Notes.",
|
||||
|
||||
@@ -39,6 +39,7 @@ import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import { setCustomViewportHeight } from '@/setViewportHeightWithFallback'
|
||||
import { WebServices } from './WebServices'
|
||||
import { FeatureName } from '@/Controllers/FeatureName'
|
||||
|
||||
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
|
||||
|
||||
@@ -204,6 +205,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
return this.isNativeMobileWeb() && this.platform === Platform.Ios
|
||||
}
|
||||
|
||||
get hideOutboundSubscriptionLinks() {
|
||||
return this.isNativeIOS()
|
||||
}
|
||||
|
||||
mobileDevice(): MobileDeviceInterface {
|
||||
if (!this.isNativeMobileWeb()) {
|
||||
throw Error('Attempting to access device as mobile device on non mobile platform')
|
||||
@@ -347,6 +352,14 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
return this.hasValidSubscription()
|
||||
}
|
||||
|
||||
get entitledToFiles(): boolean {
|
||||
return this.getViewControllerManager().featuresController.entitledToFiles
|
||||
}
|
||||
|
||||
showPremiumModal(featureName: FeatureName): void {
|
||||
void this.getViewControllerManager().featuresController.showPremiumAlert(featureName)
|
||||
}
|
||||
|
||||
hasValidSubscription(): boolean {
|
||||
return this.getViewControllerManager().subscriptionController.hasValidSubscription()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Icon from '@/Components/Icon/Icon'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
onClick: () => void
|
||||
onClick: MouseEventHandler<HTMLButtonElement>
|
||||
className?: string
|
||||
icon: IconType
|
||||
iconClassName?: string
|
||||
@@ -21,9 +21,9 @@ const IconButton: FunctionComponent<Props> = ({
|
||||
iconClassName = '',
|
||||
disabled = false,
|
||||
}) => {
|
||||
const click: MouseEventHandler = (e) => {
|
||||
const click: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
onClick(e)
|
||||
}
|
||||
const focusableClass = focusable ? '' : 'focus:shadow-none'
|
||||
return (
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import DailyContentList from './Daily/DailyContentList'
|
||||
import { ListableContentItem } from './Types/ListableContentItem'
|
||||
import { FeatureName } from '@/Controllers/FeatureName'
|
||||
|
||||
type Props = {
|
||||
accountMenuController: AccountMenuController
|
||||
@@ -123,6 +124,11 @@ const ContentListView: FunctionComponent<Props> = ({
|
||||
|
||||
const addNewItem = useCallback(async () => {
|
||||
if (isFilesSmartView) {
|
||||
if (!application.entitledToFiles) {
|
||||
application.showPremiumModal(FeatureName.Files)
|
||||
return
|
||||
}
|
||||
|
||||
if (StreamingFileReader.available()) {
|
||||
void filesController.uploadNewFile()
|
||||
return
|
||||
@@ -133,7 +139,7 @@ const ContentListView: FunctionComponent<Props> = ({
|
||||
await createNewNote()
|
||||
toggleAppPane(AppPaneId.Editor)
|
||||
}
|
||||
}, [isFilesSmartView, filesController, createNewNote, toggleAppPane])
|
||||
}, [isFilesSmartView, filesController, createNewNote, toggleAppPane, application])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ const ContentListHeader = ({
|
||||
}, [OptionsMenu, AddButton, FolderName])
|
||||
|
||||
return (
|
||||
<div className="section-title-bar-header items-start gap-1 overflow-hidden">
|
||||
<div className="section-title-bar-header items-start gap-1">
|
||||
{!isTablet && PhoneAndDesktopLayout}
|
||||
{isTablet && TabletLayout}
|
||||
</div>
|
||||
|
||||
+13
-27
@@ -20,9 +20,8 @@ import { DisplayOptionsMenuProps } from './DisplayOptionsMenuProps'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import NewNotePreferences from './NewNotePreferences'
|
||||
import { PreferenceMode } from './PreferenceMode'
|
||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscriptionBanner'
|
||||
|
||||
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 (
|
||||
<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>
|
||||
@@ -239,7 +214,18 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
|
||||
)}
|
||||
</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 />
|
||||
|
||||
|
||||
+2
-2
@@ -188,7 +188,7 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
||||
|
||||
resetState()
|
||||
|
||||
if (!featuresController.hasFiles) {
|
||||
if (!featuresController.entitledToFiles) {
|
||||
premiumModal.activate('Files')
|
||||
return
|
||||
}
|
||||
@@ -217,7 +217,7 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
||||
dragCounter.current = 0
|
||||
}
|
||||
},
|
||||
[application, featuresController.hasFiles, filesController, premiumModal, resetState],
|
||||
[application, featuresController.entitledToFiles, filesController, premiumModal, resetState],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const IconNameToSvgMapping = {
|
||||
'attachment-file': icons.AttachmentFileIcon,
|
||||
'check-bold': icons.CheckBoldIcon,
|
||||
'check-circle': icons.CheckCircleIcon,
|
||||
'chevron-up': icons.ChevronUpIcon,
|
||||
'chevron-down': icons.ChevronDownIcon,
|
||||
'chevron-left': icons.ChevronLeftIcon,
|
||||
'chevron-right': icons.ChevronRightIcon,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { EmojiString, Platform, VectorIconNameOrEmoji } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useMemo, useRef, useState } from 'react'
|
||||
import Dropdown from '../Dropdown/Dropdown'
|
||||
import { DropdownItem } from '../Dropdown/DropdownItem'
|
||||
import { getEmojiLength } from './EmojiLength'
|
||||
import { isIconEmoji } from './Icon'
|
||||
import Icon, { isIconEmoji } from './Icon'
|
||||
import { IconNameToSvgMapping } from './IconNameToSvgMapping'
|
||||
import { IconPickerType } from './IconPickerType'
|
||||
|
||||
@@ -11,13 +12,26 @@ type Props = {
|
||||
selectedValue: VectorIconNameOrEmoji
|
||||
onIconChange: (value?: string) => void
|
||||
platform: Platform
|
||||
useIconGrid?: boolean
|
||||
iconGridClassName?: string
|
||||
portalDropdown?: boolean
|
||||
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(
|
||||
() =>
|
||||
[...Object.keys(IconNameToSvgMapping)].map(
|
||||
iconKeys.map(
|
||||
(value) =>
|
||||
({
|
||||
label: value,
|
||||
@@ -25,7 +39,7 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
|
||||
icon: value,
|
||||
} as DropdownItem),
|
||||
),
|
||||
[],
|
||||
[iconKeys],
|
||||
)
|
||||
|
||||
const isSelectedEmoji = isIconEmoji(selectedValue)
|
||||
@@ -91,16 +105,36 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
|
||||
<TabButton label="Reset" type={'reset'} />
|
||||
</div>
|
||||
<div className={'mt-2 h-full min-h-0 overflow-auto'}>
|
||||
{currentType === 'icon' && (
|
||||
<Dropdown
|
||||
fullWidth={true}
|
||||
id="change-tag-icon-dropdown"
|
||||
label="Change the icon for a tag"
|
||||
items={iconOptions}
|
||||
value={selectedValue}
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
)}
|
||||
{currentType === 'icon' &&
|
||||
(useIconGrid ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex w-full flex-wrap items-center gap-6 p-1 md:max-h-24 md:gap-4 md:p-0',
|
||||
iconGridClassName,
|
||||
)}
|
||||
>
|
||||
{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' && (
|
||||
<>
|
||||
<div>
|
||||
|
||||
@@ -1,156 +1,17 @@
|
||||
import { FeatureName } from '@/Controllers/FeatureName'
|
||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
|
||||
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { ChangeEventHandler, useEffect, useRef, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import ClearInputButton from '../ClearInputButton/ClearInputButton'
|
||||
import Icon from '../Icon/Icon'
|
||||
import DecoratedInput from '../Input/DecoratedInput'
|
||||
import MenuItem from '../Menu/MenuItem'
|
||||
import { MenuItemType } from '../Menu/MenuItemType'
|
||||
import Popover from '../Popover/Popover'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
import LinkedFileMenuOptions from './LinkedFileMenuOptions'
|
||||
import LinkedItemMeta from './LinkedItemMeta'
|
||||
import LinkedItemSearchResults from './LinkedItemSearchResults'
|
||||
|
||||
const LinkedItemsSectionItem = ({
|
||||
activateItem,
|
||||
item,
|
||||
searchQuery,
|
||||
unlinkItem,
|
||||
handleFileAction,
|
||||
}: {
|
||||
activateItem: LinkingController['activateItem']
|
||||
item: LinkableItem
|
||||
searchQuery?: string
|
||||
unlinkItem: () => void
|
||||
handleFileAction: FilesController['handleFileAction']
|
||||
}) => {
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const application = useApplication()
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const toggleMenu = () => setIsMenuOpen((open) => !open)
|
||||
|
||||
const [isRenamingFile, setIsRenamingFile] = useState(false)
|
||||
|
||||
const [icon, className] = getIconForItem(item, application)
|
||||
const title = item.title ?? ''
|
||||
|
||||
const renameFile = async (name: string) => {
|
||||
if (!(item instanceof FileItem)) {
|
||||
return
|
||||
}
|
||||
await handleFileAction({
|
||||
type: PopoverFileItemActionType.RenameFile,
|
||||
payload: {
|
||||
file: item,
|
||||
name: name,
|
||||
},
|
||||
})
|
||||
setIsRenamingFile(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-between">
|
||||
{isRenamingFile && item instanceof FileItem ? (
|
||||
<div className="flex flex-grow items-center gap-4 py-2 pl-3 pr-12">
|
||||
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
|
||||
<input
|
||||
className="min-w-0 flex-grow text-sm"
|
||||
defaultValue={title}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setIsRenamingFile(false)
|
||||
} else if (event.key === KeyboardKey.Enter) {
|
||||
const newTitle = event.currentTarget.value
|
||||
void renameFile(newTitle)
|
||||
}
|
||||
}}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
node.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="flex max-w-full flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
|
||||
onClick={() => activateItem(item)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
toggleMenu()
|
||||
}}
|
||||
>
|
||||
<LinkedItemMeta item={item} searchQuery={searchQuery} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="absolute right-3 top-1/2 h-7 w-7 -translate-y-1/2 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
|
||||
onClick={toggleMenu}
|
||||
ref={menuButtonRef}
|
||||
>
|
||||
<Icon type="more" className="text-neutral" />
|
||||
</button>
|
||||
<Popover
|
||||
open={isMenuOpen}
|
||||
togglePopover={toggleMenu}
|
||||
anchorElement={menuButtonRef.current}
|
||||
side="bottom"
|
||||
align="center"
|
||||
className="py-2"
|
||||
>
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
unlinkItem()
|
||||
toggleMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="link-off" className="mr-2 text-danger" />
|
||||
Unlink
|
||||
</MenuItem>
|
||||
{item instanceof FileItem && (
|
||||
<LinkedFileMenuOptions
|
||||
file={item}
|
||||
closeMenu={toggleMenu}
|
||||
handleFileAction={handleFileAction}
|
||||
setIsRenamingFile={setIsRenamingFile}
|
||||
/>
|
||||
)}
|
||||
<HorizontalSeparator classes="my-2" />
|
||||
<div className="mt-1 px-3 py-1 text-xs font-medium text-neutral">
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Created at:</span> {formatDateForContextMenu(item.created_at)}
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Modified at:</span> {formatDateForContextMenu(item.userModifiedDate)}
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">ID:</span> {item.uuid}
|
||||
</div>
|
||||
{item instanceof FileItem && (
|
||||
<div>
|
||||
<span className="font-semibold">Size:</span> {formatSizeToReadableString(item.decryptedSize)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { LinkedItemsSectionItem } from './LinkedItemsSectionItem'
|
||||
|
||||
const LinkedItemsPanel = ({
|
||||
linkingController,
|
||||
@@ -178,7 +39,7 @@ const LinkedItemsPanel = ({
|
||||
activeItem,
|
||||
} = linkingController
|
||||
|
||||
const { hasFiles } = featuresController
|
||||
const { entitledToFiles } = featuresController
|
||||
const application = useApplication()
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -214,6 +75,11 @@ const LinkedItemsPanel = ({
|
||||
}
|
||||
|
||||
const selectAndUploadFiles = () => {
|
||||
if (!entitledToFiles) {
|
||||
void featuresController.showPremiumAlert(FeatureName.Files)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fileInputRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -307,37 +173,37 @@ const LinkedItemsPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!!linkedFiles.length || hasFiles) && (
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</div>
|
||||
<div className="my-1">
|
||||
<input
|
||||
type="file"
|
||||
className="absolute top-0 left-0 -z-50 h-px w-px opacity-0"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</div>
|
||||
<div className="my-1">
|
||||
<input
|
||||
type="file"
|
||||
className="absolute top-0 left-0 -z-50 h-px w-px opacity-0"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center gap-3 bg-transparent px-3 py-2 text-left text-base text-text hover:bg-info-backdrop hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
|
||||
onClick={selectAndUploadFiles}
|
||||
>
|
||||
<Icon type="add" />
|
||||
Upload and link file(s)
|
||||
</button>
|
||||
{linkedFiles.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center gap-3 bg-transparent px-3 py-2 text-left text-base text-text hover:bg-info-backdrop hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
|
||||
onClick={selectAndUploadFiles}
|
||||
>
|
||||
<Icon type="add" />
|
||||
Upload and link file(s)
|
||||
</button>
|
||||
{linkedFiles.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!filesLinkingToActiveItem.length && (
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import Icon from '../Icon/Icon'
|
||||
import MenuItem from '../Menu/MenuItem'
|
||||
import { MenuItemType } from '../Menu/MenuItemType'
|
||||
import Popover from '../Popover/Popover'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
import LinkedFileMenuOptions from './LinkedFileMenuOptions'
|
||||
import LinkedItemMeta from './LinkedItemMeta'
|
||||
|
||||
export const LinkedItemsSectionItem = ({
|
||||
activateItem,
|
||||
item,
|
||||
searchQuery,
|
||||
unlinkItem,
|
||||
handleFileAction,
|
||||
}: {
|
||||
activateItem: LinkingController['activateItem']
|
||||
item: LinkableItem
|
||||
searchQuery?: string
|
||||
unlinkItem: () => void
|
||||
handleFileAction: FilesController['handleFileAction']
|
||||
}) => {
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const application = useApplication()
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const toggleMenu = () => setIsMenuOpen((open) => !open)
|
||||
|
||||
const [isRenamingFile, setIsRenamingFile] = useState(false)
|
||||
|
||||
const [icon, className] = getIconForItem(item, application)
|
||||
const title = item.title ?? ''
|
||||
|
||||
const renameFile = async (name: string) => {
|
||||
if (!(item instanceof FileItem)) {
|
||||
return
|
||||
}
|
||||
await handleFileAction({
|
||||
type: PopoverFileItemActionType.RenameFile,
|
||||
payload: {
|
||||
file: item,
|
||||
name: name,
|
||||
},
|
||||
})
|
||||
setIsRenamingFile(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-between">
|
||||
{isRenamingFile && item instanceof FileItem ? (
|
||||
<div className="flex flex-grow items-center gap-4 py-2 pl-3 pr-12">
|
||||
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
|
||||
<input
|
||||
className="min-w-0 flex-grow text-sm"
|
||||
defaultValue={title}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setIsRenamingFile(false)
|
||||
} else if (event.key === KeyboardKey.Enter) {
|
||||
const newTitle = event.currentTarget.value
|
||||
void renameFile(newTitle)
|
||||
}
|
||||
}}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
node.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="flex max-w-full flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
|
||||
onClick={() => activateItem(item)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
toggleMenu()
|
||||
}}
|
||||
>
|
||||
<LinkedItemMeta item={item} searchQuery={searchQuery} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="absolute right-3 top-1/2 h-7 w-7 -translate-y-1/2 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
|
||||
onClick={toggleMenu}
|
||||
ref={menuButtonRef}
|
||||
>
|
||||
<Icon type="more" className="text-neutral" />
|
||||
</button>
|
||||
<Popover
|
||||
open={isMenuOpen}
|
||||
togglePopover={toggleMenu}
|
||||
anchorElement={menuButtonRef.current}
|
||||
side="bottom"
|
||||
align="center"
|
||||
className="py-2"
|
||||
>
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
unlinkItem()
|
||||
toggleMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="link-off" className="mr-2 text-danger" />
|
||||
Unlink
|
||||
</MenuItem>
|
||||
{item instanceof FileItem && (
|
||||
<LinkedFileMenuOptions
|
||||
file={item}
|
||||
closeMenu={toggleMenu}
|
||||
handleFileAction={handleFileAction}
|
||||
setIsRenamingFile={setIsRenamingFile}
|
||||
/>
|
||||
)}
|
||||
<HorizontalSeparator classes="my-2" />
|
||||
<div className="mt-1 px-3 py-1 text-xs font-medium text-neutral">
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Created at:</span> {formatDateForContextMenu(item.created_at)}
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Modified at:</span> {formatDateForContextMenu(item.userModifiedDate)}
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">ID:</span> {item.uuid}
|
||||
</div>
|
||||
{item instanceof FileItem && (
|
||||
<div>
|
||||
<span className="font-semibold">Size:</span> {formatSizeToReadableString(item.decryptedSize)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</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
|
||||
+1
-1
@@ -29,7 +29,7 @@ const AccountPreferences = ({ application, viewControllerManager }: Props) => (
|
||||
)}
|
||||
<Subscription application={application} viewControllerManager={viewControllerManager} />
|
||||
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
|
||||
{application.hasAccount() && viewControllerManager.featuresController.hasFiles && (
|
||||
{application.hasAccount() && viewControllerManager.featuresController.entitledToFiles && (
|
||||
<FilesSection application={application} />
|
||||
)}
|
||||
{application.hasAccount() && <Email application={application} />}
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ const NoSubscription: FunctionComponent<Props> = ({ application }) => {
|
||||
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
|
||||
{purchaseFlowError && <Text className="text-danger">{purchaseFlowError}</Text>}
|
||||
<div className="flex">
|
||||
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
|
||||
{!application.hideOutboundSubscriptionLinks && (
|
||||
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
|
||||
)}
|
||||
{application.hasAccount() && (
|
||||
<Button className="mt-3 min-w-20" primary label="Subscribe" onClick={onPurchaseClick} />
|
||||
)}
|
||||
|
||||
+3
-1
@@ -33,7 +33,9 @@ const NoProSubscription: FunctionComponent<Props> = ({ application }) => {
|
||||
{purchaseFlowError && <Text className="text-danger">{purchaseFlowError}</Text>}
|
||||
|
||||
<div className="flex">
|
||||
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
|
||||
{!application.hideOutboundSubscriptionLinks && (
|
||||
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
|
||||
)}
|
||||
{application.hasAccount() && (
|
||||
<Button className="mt-3 min-w-20" primary label="Upgrade" onClick={onPurchaseClick} />
|
||||
)}
|
||||
|
||||
@@ -65,7 +65,6 @@ const CloudLink: FunctionComponent = () => (
|
||||
Want to meet other passionate note-takers and privacy enthusiasts? Want to share your feedback with us? Join
|
||||
the Standard Notes community groups for discussions on security, themes, editors and more.
|
||||
</Text>
|
||||
<LinkButton className="mt-3" link="https://standardnotes.com/slack" label="Join our Slack" />
|
||||
<LinkButton className="mt-3" link="https://standardnotes.com/discord" label="Join our Discord" />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSe
|
||||
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
||||
import PlaintextDefaults from './PlaintextDefaults'
|
||||
import Persistence from './Persistence'
|
||||
import SmartViews from './SmartViews/SmartViews'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
@@ -22,6 +23,11 @@ const General: FunctionComponent<Props> = ({ viewControllerManager, application,
|
||||
<Persistence application={application} />
|
||||
<PlaintextDefaults application={application} />
|
||||
<Defaults application={application} />
|
||||
<SmartViews
|
||||
application={application}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
/>
|
||||
<Tools application={application} />
|
||||
<LabsPane application={application} />
|
||||
<Advanced
|
||||
|
||||
+128
@@ -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)
|
||||
+26
@@ -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
|
||||
+85
@@ -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)
|
||||
@@ -41,6 +41,14 @@ const HelpAndFeedback = ({ application }: { application: WebApplication }) => {
|
||||
Privacy Manifesto.
|
||||
</a>
|
||||
</Text>
|
||||
{application.isNativeIOS() && (
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
label="Privacy Policy"
|
||||
link="https://standardnotes.com/privacy"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<PreferencesSegment>
|
||||
|
||||
+25
-85
@@ -1,14 +1,14 @@
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import { FunctionComponent, useCallback, useRef } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { AlertDialog } from '@reach/alert-dialog'
|
||||
import { FunctionComponent, useRef } from 'react'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||
import { PremiumFeatureModalType } from './PremiumFeatureModalType'
|
||||
import { FeatureName } from '@/Controllers/FeatureName'
|
||||
import { SuccessPrompt } from './Subviews/SuccessPrompt'
|
||||
import { UpgradePrompt } from './Subviews/UpgradePrompt'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
featureName: string
|
||||
featureName: FeatureName | string
|
||||
hasSubscription: boolean
|
||||
hasAccount: boolean
|
||||
onClose: () => void
|
||||
@@ -25,92 +25,32 @@ const PremiumFeaturesModal: FunctionComponent<Props> = ({
|
||||
showModal,
|
||||
type = PremiumFeatureModalType.UpgradePrompt,
|
||||
}) => {
|
||||
const plansButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const ctaButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (hasSubscription) {
|
||||
void openSubscriptionDashboard(application)
|
||||
} else if (hasAccount) {
|
||||
void application.openPurchaseFlow()
|
||||
} else if (window.plansUrl) {
|
||||
window.location.assign(window.plansUrl)
|
||||
}
|
||||
onClose()
|
||||
}, [application, hasSubscription, hasAccount, onClose])
|
||||
if (!showModal) {
|
||||
return null
|
||||
}
|
||||
|
||||
const UpgradePrompt = (
|
||||
<>
|
||||
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
|
||||
To take advantage of <span className="font-semibold">{featureName}</span> and other advanced features, upgrade
|
||||
your current plan.
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
|
||||
ref={plansButtonRef}
|
||||
>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const SuccessPrompt = (
|
||||
<>
|
||||
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
|
||||
Enjoy your new powered up experience.
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
|
||||
ref={plansButtonRef}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const title =
|
||||
type === PremiumFeatureModalType.UpgradePrompt ? 'Enable Advanced Features' : 'Your purchase was successful!'
|
||||
|
||||
const iconName = type === PremiumFeatureModalType.UpgradePrompt ? PremiumFeatureIconName : '🎉'
|
||||
const iconClass =
|
||||
type === PremiumFeatureModalType.UpgradePrompt
|
||||
? `h-12 w-12 ${PremiumFeatureIconClass}`
|
||||
: 'px-7 py-2 h-24 w-24 text-[50px]'
|
||||
|
||||
return showModal ? (
|
||||
<AlertDialog leastDestructiveRef={plansButtonRef} className="p-0">
|
||||
return (
|
||||
<AlertDialog leastDestructiveRef={ctaButtonRef} className="p-0">
|
||||
<div tabIndex={-1} className="sn-component">
|
||||
<div tabIndex={0} className="max-w-89 rounded bg-default p-4 shadow-main">
|
||||
<AlertDialogLabel>
|
||||
<div className="flex justify-end p-1">
|
||||
<button
|
||||
className="flex cursor-pointer border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<Icon className="text-neutral" type="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<Icon className={iconClass} size={'custom'} type={iconName} />
|
||||
</div>
|
||||
<div className="mb-1 text-center text-lg font-bold">{title}</div>
|
||||
</AlertDialogLabel>
|
||||
{type === PremiumFeatureModalType.UpgradePrompt ? UpgradePrompt : SuccessPrompt}
|
||||
{type === PremiumFeatureModalType.UpgradePrompt && (
|
||||
<UpgradePrompt
|
||||
featureName={featureName}
|
||||
ctaRef={ctaButtonRef}
|
||||
application={application}
|
||||
hasAccount={hasAccount}
|
||||
hasSubscription={hasSubscription}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === PremiumFeatureModalType.UpgradeSuccess && <SuccessPrompt ctaRef={ctaButtonRef} onClose={onClose} />}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
export default PremiumFeaturesModal
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
|
||||
export const SuccessPrompt = ({
|
||||
ctaRef,
|
||||
onClose,
|
||||
}: {
|
||||
ctaRef: React.RefObject<HTMLButtonElement>
|
||||
onClose: () => void
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<AlertDialogLabel>
|
||||
<div className="flex justify-end p-1">
|
||||
<button
|
||||
className="flex cursor-pointer border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<Icon className="text-neutral" type="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<Icon className={'h-24 w-24 px-7 py-2 text-[50px]'} size={'custom'} type={'🎉'} />
|
||||
</div>
|
||||
<div className="mb-1 text-center text-lg font-bold">Your purchase was successful!</div>
|
||||
</AlertDialogLabel>
|
||||
|
||||
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
|
||||
Enjoy your new powered up experience.
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
|
||||
ref={ctaRef}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import { useCallback } from 'react'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
|
||||
|
||||
export const UpgradePrompt = ({
|
||||
featureName,
|
||||
ctaRef,
|
||||
application,
|
||||
hasSubscription,
|
||||
hasAccount,
|
||||
onClose,
|
||||
}: {
|
||||
featureName: string
|
||||
ctaRef: React.RefObject<HTMLButtonElement>
|
||||
application: WebApplication
|
||||
hasSubscription: boolean
|
||||
hasAccount: boolean
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (hasSubscription) {
|
||||
void openSubscriptionDashboard(application)
|
||||
} else if (hasAccount) {
|
||||
void application.openPurchaseFlow()
|
||||
} else if (window.plansUrl) {
|
||||
window.location.assign(window.plansUrl)
|
||||
}
|
||||
onClose()
|
||||
}, [application, hasSubscription, hasAccount, onClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialogLabel>
|
||||
<div className="flex justify-end p-1">
|
||||
<button
|
||||
className="flex cursor-pointer border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<Icon className="text-neutral" type="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<Icon className={`h-12 w-12 ${PremiumFeatureIconClass}`} size={'custom'} type={PremiumFeatureIconName} />
|
||||
</div>
|
||||
<div className="mb-1 text-center text-lg font-bold">Enable Advanced Features</div>
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
|
||||
To take advantage of <span className="font-semibold">{featureName}</span> and other advanced features, upgrade
|
||||
your current plan.
|
||||
{application.isNativeIOS() && (
|
||||
<div className="mt-2">
|
||||
<div className="mb-2 font-bold">The Professional Plan costs $99.99/year and includes benefits like</div>
|
||||
<ul className="list-inside list-[circle]">
|
||||
<li>100GB encrypted file storage</li>
|
||||
<li>Access to all note types, including markdown, rich text, authenticator, tasks, and spreadsheets</li>
|
||||
<li>Note history going back indefinitely</li>
|
||||
<li>Nested folders for your tags</li>
|
||||
<li>Premium support</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
|
||||
ref={ctaRef}
|
||||
>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { addToast, ToastType } from '@standardnotes/toast'
|
||||
import { useRef, useState } from 'react'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
}
|
||||
|
||||
const CopyableCodeBlock = ({ code }: Props) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const [didCopy, setDidCopy] = useState(false)
|
||||
const [isCopyButtonVisible, setIsCopyButtonVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative"
|
||||
onMouseEnter={() => setIsCopyButtonVisible(true)}
|
||||
onMouseLeave={() => setIsCopyButtonVisible(false)}
|
||||
>
|
||||
<pre className="overflow-auto rounded-md bg-default px-2.5 py-1.5">{code}</pre>
|
||||
<div className="absolute top-1.5 right-1.5">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={classNames(
|
||||
'peer rounded border border-border bg-default p-2 text-text hover:bg-contrast',
|
||||
!isCopyButtonVisible && 'hidden',
|
||||
)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(code).then(
|
||||
() => {
|
||||
setDidCopy(true)
|
||||
setTimeout(() => {
|
||||
setDidCopy(false)
|
||||
buttonRef.current?.blur()
|
||||
}, 1000)
|
||||
},
|
||||
() => {
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: 'Failed to copy to clipboard',
|
||||
})
|
||||
setDidCopy(false)
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Icon type="copy" size="small" />
|
||||
</button>
|
||||
<div
|
||||
className={classNames(
|
||||
didCopy && isCopyButtonVisible ? '' : 'hidden',
|
||||
'absolute top-full right-0 min-w-max translate-x-2 translate-y-1 select-none rounded border border-border bg-default py-1.5 px-3 text-left md:peer-hover:block',
|
||||
)}
|
||||
>
|
||||
{didCopy ? 'Copied!' : 'Copy example to clipboard'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyableCodeBlock
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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 { useEffect, useRef, useState } from 'react'
|
||||
import { AddSmartViewModalController } from './AddSmartViewModalController'
|
||||
import TabPanel from '../Tabs/TabPanel'
|
||||
import { useTabState } from '../Tabs/useTabState'
|
||||
import TabsContainer from '../Tabs/TabsContainer'
|
||||
import CopyableCodeBlock from '../Shared/CopyableCodeBlock'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
|
||||
type Props = {
|
||||
controller: AddSmartViewModalController
|
||||
platform: Platform
|
||||
}
|
||||
|
||||
const ConflictedNotesExampleCode = `{
|
||||
"keypath": "content.conflict_of.length",
|
||||
"operator": ">",
|
||||
"value": 0
|
||||
}`
|
||||
|
||||
const ComplexCompoundExampleCode = `{
|
||||
"operator": "and",
|
||||
"value": [
|
||||
{
|
||||
"operator": "not",
|
||||
"value": {
|
||||
"keypath": "tags",
|
||||
"operator": "includes",
|
||||
"value": {
|
||||
"keypath": "title",
|
||||
"operator": "=",
|
||||
"value": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"keypath": "tags",
|
||||
"operator": "includes",
|
||||
"value": {
|
||||
"keypath": "title",
|
||||
"operator": "=",
|
||||
"value": "todo"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
const AddSmartViewModal = ({ controller, platform }: Props) => {
|
||||
const {
|
||||
isSaving,
|
||||
title,
|
||||
setTitle,
|
||||
icon,
|
||||
setIcon,
|
||||
closeModal,
|
||||
saveCurrentSmartView,
|
||||
predicateController,
|
||||
customPredicateJson,
|
||||
setCustomPredicateJson,
|
||||
isCustomJsonValidPredicate,
|
||||
setIsCustomJsonValidPredicate,
|
||||
validateAndPrettifyCustomPredicate,
|
||||
} = controller
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||
const customJsonInputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
|
||||
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [shouldShowJsonExamples, setShouldShowJsonExamples] = useState(false)
|
||||
|
||||
const toggleIconPicker = () => {
|
||||
setShouldShowIconPicker((shouldShow) => !shouldShow)
|
||||
}
|
||||
|
||||
const tabState = useTabState({
|
||||
defaultTab: 'builder',
|
||||
})
|
||||
|
||||
const save = () => {
|
||||
if (!title.length) {
|
||||
titleInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (tabState.activeTab === 'custom' && !isCustomJsonValidPredicate) {
|
||||
validateAndPrettifyCustomPredicate()
|
||||
return
|
||||
}
|
||||
|
||||
void saveCurrentSmartView()
|
||||
}
|
||||
|
||||
const canSave = tabState.activeTab === 'builder' || isCustomJsonValidPredicate
|
||||
|
||||
useEffect(() => {
|
||||
if (!customJsonInputRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabState.activeTab === 'custom' && isCustomJsonValidPredicate === false) {
|
||||
customJsonInputRef.current.focus()
|
||||
}
|
||||
}, [isCustomJsonValidPredicate, tabState.activeTab])
|
||||
|
||||
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>
|
||||
<TabsContainer
|
||||
tabs={[
|
||||
{
|
||||
id: 'builder',
|
||||
title: 'Builder',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
title: 'Custom (JSON)',
|
||||
},
|
||||
]}
|
||||
state={tabState}
|
||||
>
|
||||
<TabPanel state={tabState} id="builder" className="flex flex-col gap-2.5 p-4">
|
||||
<CompoundPredicateBuilder controller={predicateController} />
|
||||
</TabPanel>
|
||||
<TabPanel state={tabState} id="custom">
|
||||
<textarea
|
||||
className="h-full min-h-[10rem] w-full resize-none bg-default py-1.5 px-2.5 font-mono text-sm"
|
||||
value={customPredicateJson}
|
||||
onChange={(event) => {
|
||||
setCustomPredicateJson(event.target.value)
|
||||
setIsCustomJsonValidPredicate(undefined)
|
||||
}}
|
||||
spellCheck={false}
|
||||
ref={customJsonInputRef}
|
||||
/>
|
||||
{customPredicateJson && isCustomJsonValidPredicate === false && (
|
||||
<div className="mt-2 border-t border-border px-2.5 py-1.5 text-sm text-danger">
|
||||
Invalid JSON. Double check your entry and try again.
|
||||
</div>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabsContainer>
|
||||
{tabState.activeTab === 'custom' && (
|
||||
<Disclosure open={shouldShowJsonExamples} onChange={() => setShouldShowJsonExamples((show) => !show)}>
|
||||
<div className="flex flex-col gap-1.5 rounded-md border-2 border-info-backdrop bg-info-backdrop py-3 px-4">
|
||||
<DisclosureButton className="flex items-center justify-between focus:shadow-none focus:outline-none">
|
||||
<div className="text-sm font-semibold">Examples</div>
|
||||
<Icon type={shouldShowJsonExamples ? 'chevron-up' : 'chevron-down'} />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel className={classNames(shouldShowJsonExamples && 'flex', 'flex-col gap-2.5')}>
|
||||
<div className="text-sm font-medium">1. List notes that are conflicted copies of another note:</div>
|
||||
<CopyableCodeBlock code={ConflictedNotesExampleCode} />
|
||||
<div className="text-sm font-medium">
|
||||
2. List notes that have the tag `todo` but not the tag `completed`:
|
||||
</div>
|
||||
<CopyableCodeBlock code={ComplexCompoundExampleCode} />
|
||||
</DisclosurePanel>
|
||||
</div>
|
||||
</Disclosure>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialogDescription>
|
||||
<ModalDialogButtons>
|
||||
<Button disabled={isSaving} onClick={closeModal} className="mr-auto">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isSaving} onClick={save} colorStyle={canSave ? 'info' : 'default'} primary={canSave}>
|
||||
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : canSave ? 'Save' : 'Validate'}
|
||||
</Button>
|
||||
</ModalDialogButtons>
|
||||
</ModalDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(AddSmartViewModal)
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { CompoundPredicateBuilderController } from '@/Components/SmartViewBuilder/CompoundPredicateBuilderController'
|
||||
import { predicateFromJson, PredicateJsonForm } from '@standardnotes/snjs'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
|
||||
export class AddSmartViewModalController {
|
||||
isAddingSmartView = false
|
||||
isSaving = false
|
||||
|
||||
title = ''
|
||||
|
||||
icon = 'restore'
|
||||
|
||||
predicateController = new CompoundPredicateBuilderController()
|
||||
|
||||
customPredicateJson: string | undefined = undefined
|
||||
isCustomJsonValidPredicate: boolean | undefined = undefined
|
||||
|
||||
constructor(private application: WebApplication) {
|
||||
makeObservable(this, {
|
||||
isAddingSmartView: observable,
|
||||
setIsAddingSmartView: action,
|
||||
|
||||
isSaving: observable,
|
||||
setIsSaving: action,
|
||||
|
||||
title: observable,
|
||||
setTitle: action,
|
||||
|
||||
icon: observable,
|
||||
setIcon: action,
|
||||
|
||||
customPredicateJson: observable,
|
||||
isCustomJsonValidPredicate: observable,
|
||||
setCustomPredicateJson: action,
|
||||
setIsCustomJsonValidPredicate: 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
|
||||
}
|
||||
|
||||
setCustomPredicateJson = (customPredicateJson: string) => {
|
||||
this.customPredicateJson = customPredicateJson
|
||||
}
|
||||
|
||||
setIsCustomJsonValidPredicate = (isCustomJsonValidPredicate: boolean | undefined) => {
|
||||
this.isCustomJsonValidPredicate = isCustomJsonValidPredicate
|
||||
}
|
||||
|
||||
closeModal = () => {
|
||||
this.setIsAddingSmartView(false)
|
||||
this.setTitle('')
|
||||
this.setIcon('')
|
||||
this.setIsSaving(false)
|
||||
this.predicateController.resetState()
|
||||
this.setCustomPredicateJson('')
|
||||
this.setIsCustomJsonValidPredicate(undefined)
|
||||
}
|
||||
|
||||
saveCurrentSmartView = async () => {
|
||||
this.setIsSaving(true)
|
||||
|
||||
if (!this.title) {
|
||||
this.setIsSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const predicateJson =
|
||||
this.customPredicateJson && this.isCustomJsonValidPredicate
|
||||
? JSON.parse(this.customPredicateJson)
|
||||
: this.predicateController.toJson()
|
||||
const predicate = predicateFromJson(predicateJson as PredicateJsonForm)
|
||||
await this.application.items.createSmartView(this.title, predicate, this.icon)
|
||||
|
||||
this.setIsSaving(false)
|
||||
this.closeModal()
|
||||
}
|
||||
|
||||
validateAndPrettifyCustomPredicate = () => {
|
||||
if (!this.customPredicateJson) {
|
||||
this.setIsCustomJsonValidPredicate(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedPredicate: PredicateJsonForm = JSON.parse(this.customPredicateJson)
|
||||
const predicate = predicateFromJson(parsedPredicate)
|
||||
|
||||
if (predicate) {
|
||||
this.setCustomPredicateJson(JSON.stringify(parsedPredicate, null, 2))
|
||||
this.setIsCustomJsonValidPredicate(true)
|
||||
} else {
|
||||
this.setIsCustomJsonValidPredicate(false)
|
||||
}
|
||||
} catch (error) {
|
||||
this.setIsCustomJsonValidPredicate(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -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 flex-col gap-2 md:flex-row md:items-center">
|
||||
{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)
|
||||
+92
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { ComponentPropsWithoutRef } from 'react'
|
||||
import { useTabStateContext } from './useTabState'
|
||||
|
||||
type Props = { id: string } & ComponentPropsWithoutRef<'button'>
|
||||
|
||||
const Tab = ({ id, className, children, ...props }: Props) => {
|
||||
const { state } = useTabStateContext()
|
||||
const { activeTab, setActiveTab } = state
|
||||
|
||||
const isActive = activeTab === id
|
||||
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
id={`tab-control-${id}`}
|
||||
onClick={() => {
|
||||
setActiveTab(id)
|
||||
}}
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tab-panel-${id}`}
|
||||
className={classNames(
|
||||
'relative cursor-pointer border-0 bg-default px-3 py-2.5 text-sm focus:shadow-inner',
|
||||
isActive ? 'font-medium text-info' : 'text-text',
|
||||
isActive && 'after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-full after:bg-info',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tab
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentPropsWithoutRef, useMemo } from 'react'
|
||||
import { TabStateContext, TabState } from './useTabState'
|
||||
|
||||
type Props = {
|
||||
state: TabState
|
||||
} & ComponentPropsWithoutRef<'div'>
|
||||
|
||||
const TabList = ({ state, children, ...props }: Props) => {
|
||||
const providerValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
}),
|
||||
[state],
|
||||
)
|
||||
|
||||
return (
|
||||
<TabStateContext.Provider value={providerValue}>
|
||||
<div role="tablist" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TabStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabList
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ComponentPropsWithoutRef } from 'react'
|
||||
import { TabState } from './useTabState'
|
||||
|
||||
type Props = { state: TabState; id: string } & ComponentPropsWithoutRef<'div'>
|
||||
|
||||
const TabPanel = ({ state, id, children, ...props }: Props) => {
|
||||
const { activeTab } = state
|
||||
|
||||
const isActive = activeTab === id
|
||||
|
||||
if (!isActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="tabpanel" id={`tab-panel-${id}`} aria-labelledby={`tab-control-${id}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabPanel
|
||||
@@ -0,0 +1,29 @@
|
||||
import Tab from './Tab'
|
||||
import TabList from './TabList'
|
||||
import { TabState } from './useTabState'
|
||||
|
||||
type Props = {
|
||||
tabs: {
|
||||
id: string
|
||||
title: string
|
||||
}[]
|
||||
state: TabState
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const TabsContainer = ({ tabs, state, children }: Props) => {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
<TabList state={state} className="border-b border-border">
|
||||
{tabs.map(({ id, title }) => (
|
||||
<Tab key={id} id={id} className="first:rounded-tl-md">
|
||||
{title}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabsContainer
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
|
||||
export const useTabState = ({ defaultTab }: { defaultTab: string }) => {
|
||||
const [activeTab, setActiveTab] = useState(defaultTab)
|
||||
|
||||
return { activeTab, setActiveTab }
|
||||
}
|
||||
|
||||
export type TabState = ReturnType<typeof useTabState>
|
||||
|
||||
type TabContextValue = {
|
||||
state: TabState
|
||||
}
|
||||
|
||||
export const TabStateContext = createContext<TabContextValue | undefined>(undefined)
|
||||
|
||||
export const useTabStateContext = () => {
|
||||
const context = useContext(TabStateContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useTabStateContext must be used within a <TabList/>')
|
||||
}
|
||||
|
||||
if (context.state === undefined) {
|
||||
throw new Error('Tab state must be provided to the parent <TabList/>')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -57,7 +57,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
)}
|
||||
>
|
||||
@@ -135,14 +135,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
|
||||
'md:hover:[overflow-y:_overlay]',
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<SmartViewsSection viewControllerManager={viewControllerManager} />
|
||||
<SmartViewsSection application={application} viewControllerManager={viewControllerManager} />
|
||||
<TagsSection viewControllerManager={viewControllerManager} />
|
||||
</div>
|
||||
{NavigationFooter}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import { SmartView } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'react'
|
||||
import SmartViewsListItem from './SmartViewsListItem'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
setEditingSmartView: (smartView: SmartView) => void
|
||||
}
|
||||
|
||||
const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) => {
|
||||
const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager, setEditingSmartView }: Props) => {
|
||||
const allViews = viewControllerManager.navigationController.smartViews
|
||||
|
||||
return (
|
||||
@@ -19,6 +21,7 @@ const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager }: Pro
|
||||
view={view}
|
||||
tagsState={viewControllerManager.navigationController}
|
||||
features={viewControllerManager.featuresController}
|
||||
setEditingSmartView={setEditingSmartView}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Props = {
|
||||
view: SmartView
|
||||
tagsState: NavigationController
|
||||
features: FeaturesController
|
||||
setEditingSmartView: (smartView: SmartView) => void
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
||||
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEditingSmartView }) => {
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
|
||||
const [title, setTitle] = useState(view.title || '')
|
||||
@@ -85,13 +86,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
||||
}
|
||||
}, [inputRef, isEditing])
|
||||
|
||||
const onClickRename = useCallback(() => {
|
||||
tagsState.setEditingTag(view)
|
||||
}, [tagsState, view])
|
||||
|
||||
const onClickSave = useCallback(() => {
|
||||
inputRef.current?.blur()
|
||||
}, [inputRef])
|
||||
const onClickEdit = useCallback(() => {
|
||||
setEditingSmartView(view)
|
||||
}, [setEditingSmartView, view])
|
||||
|
||||
const onClickDelete = useCallback(() => {
|
||||
tagsState.remove(view, true).catch(console.error)
|
||||
@@ -107,6 +104,11 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
className={classNames('tag px-3.5', isSelected && 'selected', isFaded && 'opacity-50')}
|
||||
onClick={selectCurrentTag}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClickEdit()
|
||||
}}
|
||||
style={{
|
||||
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
||||
}}
|
||||
@@ -147,16 +149,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
||||
|
||||
{isSelected && (
|
||||
<div className="menu">
|
||||
{!isEditing && (
|
||||
<a className="item" onClick={onClickRename}>
|
||||
Rename
|
||||
</a>
|
||||
)}
|
||||
{isEditing && (
|
||||
<a className="item" onClick={onClickSave}>
|
||||
Save
|
||||
</a>
|
||||
)}
|
||||
<a className="item" onClick={onClickEdit}>
|
||||
Edit
|
||||
</a>
|
||||
<a className="item" onClick={onClickDelete}>
|
||||
Delete
|
||||
</a>
|
||||
|
||||
@@ -1,16 +1,66 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { SMART_TAGS_FEATURE_NAME } from '@/Constants/Constants'
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { SmartView } from '@standardnotes/snjs'
|
||||
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'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
|
||||
selectedValue={selectedTag.iconString}
|
||||
platform={application.platform}
|
||||
className={'px-3 py-1.5'}
|
||||
useIconGrid={true}
|
||||
iconGridClassName="max-h-30"
|
||||
/>
|
||||
<HorizontalSeparator classes="my-2" />
|
||||
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickStar}>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum FeatureName {
|
||||
Files = 'Encrypted File Storage',
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FeatureName } from './FeatureName'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { PremiumFeatureModalType } from '@/Components/PremiumFeaturesModal/PremiumFeatureModalType'
|
||||
import { destroyAllObjectProperties } from '@/Utils'
|
||||
@@ -15,7 +16,7 @@ import { CrossControllerEvent } from './CrossControllerEvent'
|
||||
export class FeaturesController extends AbstractViewController {
|
||||
hasFolders: boolean
|
||||
hasSmartViews: boolean
|
||||
hasFiles: boolean
|
||||
entitledToFiles: boolean
|
||||
premiumAlertFeatureName: string | undefined
|
||||
premiumAlertType: PremiumFeatureModalType | undefined = undefined
|
||||
|
||||
@@ -25,7 +26,7 @@ export class FeaturesController extends AbstractViewController {
|
||||
;(this.closePremiumAlert as unknown) = undefined
|
||||
;(this.hasFolders as unknown) = undefined
|
||||
;(this.hasSmartViews as unknown) = undefined
|
||||
;(this.hasFiles as unknown) = undefined
|
||||
;(this.entitledToFiles as unknown) = undefined
|
||||
;(this.premiumAlertFeatureName as unknown) = undefined
|
||||
;(this.premiumAlertType as unknown) = undefined
|
||||
|
||||
@@ -37,7 +38,7 @@ export class FeaturesController extends AbstractViewController {
|
||||
|
||||
this.hasFolders = this.isEntitledToFolders()
|
||||
this.hasSmartViews = this.isEntitledToSmartViews()
|
||||
this.hasFiles = this.isEntitledToFiles()
|
||||
this.entitledToFiles = this.isEntitledToFiles()
|
||||
this.premiumAlertFeatureName = undefined
|
||||
|
||||
eventBus.addEventHandler(this, CrossControllerEvent.DisplayPremiumModal)
|
||||
@@ -45,7 +46,7 @@ export class FeaturesController extends AbstractViewController {
|
||||
makeObservable(this, {
|
||||
hasFolders: observable,
|
||||
hasSmartViews: observable,
|
||||
hasFiles: observable,
|
||||
entitledToFiles: observable,
|
||||
premiumAlertType: observable,
|
||||
premiumAlertFeatureName: observable,
|
||||
showPremiumAlert: action,
|
||||
@@ -67,7 +68,7 @@ export class FeaturesController extends AbstractViewController {
|
||||
runInAction(() => {
|
||||
this.hasFolders = this.isEntitledToFolders()
|
||||
this.hasSmartViews = this.isEntitledToSmartViews()
|
||||
this.hasFiles = this.isEntitledToFiles()
|
||||
this.entitledToFiles = this.isEntitledToFiles()
|
||||
})
|
||||
}
|
||||
}),
|
||||
@@ -81,7 +82,7 @@ export class FeaturesController extends AbstractViewController {
|
||||
}
|
||||
}
|
||||
|
||||
public async showPremiumAlert(featureName: string): Promise<void> {
|
||||
public async showPremiumAlert(featureName: FeatureName | string): Promise<void> {
|
||||
this.premiumAlertFeatureName = featureName
|
||||
this.premiumAlertType = PremiumFeatureModalType.UpgradePrompt
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ module.exports = {
|
||||
extend: {
|
||||
spacing: {
|
||||
4.5: '1.125rem',
|
||||
5.5: '1.325rem',
|
||||
8.5: '2.125rem',
|
||||
13: '3.25rem',
|
||||
15: '3.75rem',
|
||||
|
||||
Reference in New Issue
Block a user