Compare commits

..

111 Commits
auth ... master

Author SHA1 Message Date
2880dd7a03 tabSize: 4 2023-12-09 11:29:00 +01:00
10c0387eb0 updates 2023-12-08 22:42:48 +01:00
aee6ecfbfe upgrade daisyui 2023-12-08 22:06:41 +01:00
e2472c522e update DaisyUI 2023-07-26 17:55:20 +02:00
9933f730ad fix active color 2023-06-14 20:39:36 +02:00
a4c25b8342 update to DaisyUI 3.1.0 2023-06-14 00:06:27 +02:00
4641c09b5d disable GH and MS auth - not working native 2023-06-08 23:16:46 +02:00
8b4744446f fixes 2023-06-08 01:54:12 +02:00
cf287f31ab fixes 2023-06-08 22:22:29 +02:00
3e6fb75575 Merge branch 'master' of ssh://git.crapts.org:10022/marco/contexted-v3 2023-06-08 00:46:35 +02:00
2e5b5cf74d improve haptics 2023-06-08 00:46:17 +02:00
dab21cdc44 small ui fix 2023-06-08 00:37:09 +02:00
8828c98795 android icon logos 2023-06-07 20:01:38 +02:00
ddd6a33d56 fix auth modal on mobile 2023-06-06 23:45:50 +02:00
339e74e53e ios icons and splash 2023-06-06 23:39:13 +02:00
5b267356db add logos and splashscreen plugin 2023-06-06 23:11:58 +02:00
2c830c61ea small ui fixes 2023-06-06 22:27:00 +02:00
a617758459 add iOS 2023-06-06 21:38:32 +02:00
83d63038c5 remove statusbar and keyboard plugin errors 2023-06-06 20:41:31 +02:00
a10bfcf42d remove firebaseapp global 2023-06-06 20:40:01 +02:00
6ab24d6236 bug fixes 2023-06-06 20:29:42 +02:00
dc8dcb1867 complete capacitor firebase integration 2023-06-06 20:19:50 +02:00
53b364e798 configure Firebase Android 2023-06-06 19:47:48 +02:00
882c55373b capacitor firebase auth 2023-06-06 19:33:43 +02:00
cd807d7ff0 small ui fixes 2023-06-03 23:04:57 +02:00
33f23f838d run prettier 2023-06-02 00:04:18 +02:00
c984cc7a7b fix keyboard and haptics 2023-06-01 23:17:41 +02:00
a66b838f46 fix statusbar 2023-06-01 22:08:55 +02:00
8248c4ad49 fix table layout 2023-06-01 22:02:54 +02:00
27ba4c6b3d fix iphone padding 2023-06-01 21:32:25 +02:00
Marco Crapts
8fe704744f fix iphone padding 2023-06-01 21:31:18 +02:00
Marco Crapts
2f4c4aa7fd update ios project 2023-06-01 17:41:41 +02:00
19e99fcc28 daisyUI 3.0 2023-06-01 13:42:35 +02:00
1e742826cf add Android app link 2023-06-01 00:21:14 +02:00
af054ac61c fix list view 2023-05-31 21:26:59 +02:00
c57d0be562 redirect signin 2023-05-31 21:14:59 +02:00
de12d22434 haptics 2023-05-31 20:14:46 +02:00
9cd6382bfc native dialog on android & ios 2023-05-31 00:29:10 +02:00
ec802259a8 create ios and android targets 2023-05-30 23:06:11 +02:00
4faaf8c613 Merge branch 'master' into capacitor 2023-05-30 22:54:27 +02:00
f634e8cd76 persistent local cache 2023-05-30 22:54:14 +02:00
ab54088ffb update .env 2023-05-30 00:56:29 +02:00
45fd39022d add Capacitor 2023-05-30 00:45:45 +02:00
bd9b926550 public Firebase keys 2023-05-30 00:19:34 +02:00
70b3319778 small update 2023-05-29 22:10:27 +02:00
0fac36b5b7 ui improvements 2023-05-29 01:54:59 +02:00
33b38774df delete account 2023-05-29 01:48:46 +02:00
21222abad4 delete account ui functionality 2023-05-28 23:29:42 +02:00
06bfd4cd9e delete account ui functionality 2023-05-28 23:21:59 +02:00
b3f740770a slight refactoring 2023-05-28 22:23:41 +02:00
b4eab2d8e8 enable/disable e2e encryption 2023-05-28 21:45:47 +02:00
77f5bafa2f settings modal 2023-05-28 18:35:27 +02:00
87729c9c00 refactor to UI components 2023-05-26 01:44:20 +02:00
9ca0bba526 refactor to UI components 2023-05-26 00:50:19 +02:00
b89816ecd5 fix tailwind css 2023-05-26 19:29:52 +02:00
dd811c3f66 more ui components 2023-05-26 19:21:27 +02:00
2088b12242 more ui components 2023-05-26 17:32:43 +02:00
c3bd807bff refactor to ui components 2023-05-26 16:43:12 +02:00
a642fcd1fb collapse sidebar on resize 2023-05-25 22:56:56 +02:00
e384495e73 delete notes & sync to firebase 2023-05-25 22:29:40 +02:00
2b7ba1faf7 passphrase validation 2023-05-25 20:29:24 +02:00
3db4fe87a6 fix overlay 2023-05-25 17:10:57 +02:00
bf35285272 fix layout 2023-05-25 16:41:05 +02:00
6b7debc811 fix layout 2023-05-25 10:50:40 +02:00
84376263c8 fix click outside 2023-05-25 00:11:18 +02:00
d62444f65e store settings in localstorage 2023-05-24 23:30:28 +02:00
d6480a7a37 improve sidebar mobile 2023-05-23 23:01:02 +02:00
eb02ab7648 click outside sidebar collapse on mobile 2023-05-23 22:49:36 +02:00
ef1830aaf1 less recent items 2023-05-23 22:13:25 +02:00
511b53f073 tabs 2023-05-23 22:12:23 +02:00
f0d302a46a update search results 2023-05-23 21:53:55 +02:00
65694e530c update search results 2023-05-23 21:16:48 +02:00
b9f283a180 refactor colors 2023-05-23 13:29:00 +02:00
bd1eadb7cb better animated sidebar 2023-05-23 13:18:43 +02:00
aa77296d00 passphrase prompt 2023-05-23 00:44:51 +02:00
029893830f improve loading 2023-05-22 20:56:54 +02:00
7ca2b25e8f decrypt notes 2023-05-22 20:48:53 +02:00
55a281581e cleanup css 2023-05-22 10:58:29 +02:00
1b46687b6a fix stretch + default notes 2023-05-22 10:02:52 +02:00
833d1b34b7 try catch json parse 2023-05-22 09:08:06 +02:00
15786aafa1 add word count to note list 2023-05-22 00:27:06 +02:00
d28d57388b update signout dialog 2023-05-22 00:23:25 +02:00
c2cbe513d1 sync with local storage 2023-05-22 00:19:46 +02:00
6dd8c2d524 default sidebar collapse mobile 2023-05-21 13:56:42 +02:00
7eeedd0eeb fix jumpy safari 2023-05-21 13:28:02 +02:00
b7c38a8b2a fix note title too wide 2023-05-21 12:00:20 +02:00
84725cd1c2 single firebaseui import 2023-05-21 11:28:19 +02:00
e456ed2f5c rename close to cancel 2023-05-21 01:52:08 +02:00
1160165cf5 add modal titles 2023-05-20 15:41:37 +02:00
6c4bd5b0ae signout icon 2023-05-20 15:33:51 +02:00
7a4748c2a8 dropdown menu 2023-05-20 15:26:15 +02:00
745cf7b648 skeleton loader 2023-05-20 15:00:55 +02:00
b71bffc064 skeleton loader 2023-05-20 14:54:41 +02:00
7c40017b05 show auth modal in case of redirect 2023-05-20 12:38:55 +02:00
b4c9076986 fix auth 2023-05-20 12:14:48 +02:00
95359c792a auth loader 2023-05-20 07:13:06 +02:00
893b24354a working auth 2023-05-20 06:53:47 +02:00
684a1d76bf add auth modal 2023-05-20 04:05:36 +02:00
2990e5ec89 create link without autocomplete 2023-05-20 02:35:11 +02:00
2fc81d5da6 add firebase 2023-05-19 23:38:43 +02:00
0f2b17dab9 mindmap 2023-05-19 22:07:58 +02:00
99290b807a listview 2023-05-19 19:12:55 +02:00
83c58d5aca update project config to match official create vue recommendations 2023-05-19 14:16:44 +02:00
e491f52e26 update list view 2023-05-19 11:00:18 +02:00
95648988ef listview component 2023-05-17 07:43:53 +02:00
0c4c7782e0 improve scrolling / layout 2023-05-17 07:24:26 +02:00
e11438c952 flex-grow instead of flex-1 2023-05-17 05:27:59 +02:00
41c0ed5811 add favicons 2023-05-17 04:20:08 +02:00
8774d74163 create note on click if does not exist 2023-05-17 03:47:48 +02:00
add3e944f6 improved autocomplete 2023-05-17 03:42:10 +02:00
a189dac30f rename types to types.d.ts & fix Contexted logo 2023-05-17 15:27:12 +02:00
99 changed files with 3083 additions and 2618 deletions

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/prettierrc", "$schema": "https://json.schemastore.org/prettierrc",
"semi": false, "semi": false,
"tabWidth": 2, "tabWidth": 4,
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"trailingComma": "none", "trailingComma": "none",

View File

@@ -1,2 +1,11 @@
pretty: pretty:
npx prettier --write "./src/**/*.(ts|vue)" npx prettier --write "./src/**/*.(ts|vue)"
check:
npm run type-check && npm run lint
local: check
npm run local
deploy: check
npm run deploy

View File

@@ -9,9 +9,11 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-firebase-authentication')
implementation project(':capacitor-dialog') implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics') implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard') implementation project(':capacitor-keyboard')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar') implementation project(':capacitor-status-bar')
} }

View File

@@ -0,0 +1,40 @@
{
"project_info": {
"project_number": "1048273547256",
"firebase_url": "https://contexted-f8b4e.firebaseio.com",
"project_id": "contexted-f8b4e",
"storage_bucket": "contexted-f8b4e.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1048273547256:android:878e44df2e5f0b200f700d",
"android_client_info": {
"package_name": "com.contexted.app"
}
},
"oauth_client": [
{
"client_id": "1048273547256-mq8b1irdiovpblrdcuf8bb30is29rfm9.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCJ-A3ziDw_2qMWnP2uXFDoY2O8DrUlXp8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "1048273547256-mq8b1irdiovpblrdcuf8bb30is29rfm9.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#1E4BC4</color>
</resources> </resources>

View File

@@ -2,6 +2,9 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-firebase-authentication'
project(':capacitor-firebase-authentication').projectDir = new File('../node_modules/@capacitor-firebase/authentication/android')
include ':capacitor-dialog' include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
@@ -11,5 +14,8 @@ project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-keyboard' include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
include ':capacitor-status-bar' include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

View File

@@ -13,4 +13,5 @@ ext {
androidxJunitVersion = '1.1.5' androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1' androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1' cordovaAndroidVersion = '10.1.1'
rgcfaIncludeGoogle = true
} }

View File

@@ -8,8 +8,15 @@ const config: CapacitorConfig = {
androidScheme: 'https' androidScheme: 'https'
}, },
plugins: { plugins: {
SplashScreen: {
backgroundColor: '#1E4BC4'
},
Keyboard: { Keyboard: {
resize: 'native' resize: 'native'
},
FirebaseAuthentication: {
skipNativeAuth: false,
providers: ['google.com', 'microsoft.com', 'github.com']
} }
} }
} }

View File

@@ -15,6 +15,7 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
A9A186E62A2FB826009CBA16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9A186E52A2FB826009CBA16 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -27,6 +28,7 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
A9A186E52A2FB826009CBA16 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
@@ -74,6 +76,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
50379B222058CBB4000EE86E /* capacitor.config.json */, 50379B222058CBB4000EE86E /* capacitor.config.json */,
A9A186E52A2FB826009CBA16 /* GoogleService-Info.plist */,
504EC3071FED79650016851F /* AppDelegate.swift */, 504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */, 504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */, 504EC30E1FED79650016851F /* Assets.xcassets */,
@@ -161,6 +164,7 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */, 504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */, 2FAD9763203C412B000D30F8 /* config.xml in Resources */,
A9A186E62A2FB826009CBA16 /* GoogleService-Info.plist in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -347,6 +351,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5XQS3G6YV7;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -367,6 +372,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5XQS3G6YV7;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "AppIcon-512@2x.png", "filename" : "AppIcon-1024@2x.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

View File

@@ -1,23 +1,23 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "splash-landscape@1x.png",
"idiom" : "universal", "idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"filename" : "splash-landscape@2x.png",
"idiom" : "universal", "idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"filename" : "splash-landscape@3x.png",
"idiom" : "universal", "idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x" "scale" : "3x"
} }
], ],
"info" : { "info" : {
"version" : 1, "author" : "xcode",
"author" : "xcode" "version" : 1
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>1048273547256-fes4qqj6po81ki21doggnst5ptjsa940.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.1048273547256-fes4qqj6po81ki21doggnst5ptjsa940</string>
<key>ANDROID_CLIENT_ID</key>
<string>1048273547256-qq9j4hcvni377p8cva7v10u9us1pjrc9.apps.googleusercontent.com</string>
<key>API_KEY</key>
<string>AIzaSyCkN_Fm0wGcDZJJ6ltz5NHDvs9zPgKiSTQ</string>
<key>GCM_SENDER_ID</key>
<string>1048273547256</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.contexted.app</string>
<key>PROJECT_ID</key>
<string>contexted-f8b4e</string>
<key>STORAGE_BUCKET</key>
<string>contexted-f8b4e.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:1048273547256:ios:e57d78bd483100900f700d</string>
<key>DATABASE_URL</key>
<string>https://contexted-f8b4e.firebaseio.com</string>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Contexted</string> <string>Contexted</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -18,6 +18,19 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Firebase</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.1048273547256-fes4qqj6po81ki21doggnst5ptjsa940</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>

View File

@@ -11,17 +11,29 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorFirebaseAuthentication', :path => '../../node_modules/@capacitor-firebase/authentication'
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog' pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
end end
target 'App' do target 'App' do
capacitor_pods capacitor_pods
# Add your Pods here # Add your Pods here
pod 'CapacitorFirebaseAuthentication/Google', :path => '../../node_modules/@capacitor-firebase/authentication'
end end
post_install do |installer| post_install do |installer|
assertDeploymentTarget(installer) assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle"
target.build_configurations.each do |config|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
end
end end

View File

@@ -1,24 +1,96 @@
PODS: PODS:
- AppAuth (1.6.2):
- AppAuth/Core (= 1.6.2)
- AppAuth/ExternalUserAgent (= 1.6.2)
- AppAuth/Core (1.6.2)
- AppAuth/ExternalUserAgent (1.6.2):
- AppAuth/Core
- Capacitor (5.0.4): - Capacitor (5.0.4):
- CapacitorCordova - CapacitorCordova
- CapacitorCordova (5.0.4) - CapacitorCordova (5.0.4)
- CapacitorDialog (5.0.2): - CapacitorDialog (5.0.2):
- Capacitor - Capacitor
- CapacitorFirebaseAuthentication (5.0.0):
- Capacitor
- CapacitorFirebaseAuthentication/Lite (= 5.0.0)
- FirebaseAuth (= 10.8.0)
- CapacitorFirebaseAuthentication/Google (5.0.0):
- Capacitor
- FirebaseAuth (= 10.8.0)
- GoogleSignIn (= 7.0.0)
- CapacitorFirebaseAuthentication/Lite (5.0.0):
- Capacitor
- FirebaseAuth (= 10.8.0)
- CapacitorHaptics (5.0.2): - CapacitorHaptics (5.0.2):
- Capacitor - Capacitor
- CapacitorKeyboard (5.0.2): - CapacitorKeyboard (5.0.2):
- Capacitor - Capacitor
- CapacitorSplashScreen (5.0.2):
- Capacitor
- CapacitorStatusBar (5.0.2): - CapacitorStatusBar (5.0.2):
- Capacitor - Capacitor
- FirebaseAppCheckInterop (10.10.0)
- FirebaseAuth (10.8.0):
- FirebaseAppCheckInterop (~> 10.0)
- FirebaseCore (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
- GoogleUtilities/Environment (~> 7.8)
- GTMSessionFetcher/Core (< 4.0, >= 2.1)
- FirebaseCore (10.10.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreInternal (10.10.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- GoogleSignIn (7.0.0):
- AppAuth (~> 1.5)
- GTMAppAuth (< 3.0, >= 1.3)
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.1):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.11.1):
- GoogleUtilities/Environment
- GoogleUtilities/Network (7.11.1):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.1)"
- GoogleUtilities/Reachability (7.11.1):
- GoogleUtilities/Logger
- GTMAppAuth (2.0.0):
- AppAuth/Core (~> 1.6)
- GTMSessionFetcher/Core (< 4.0, >= 1.5)
- GTMSessionFetcher/Core (3.1.1)
- PromisesObjC (2.2.0)
DEPENDENCIES: DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)" - "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)" - "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)"
- "CapacitorFirebaseAuthentication (from `../../node_modules/@capacitor-firebase/authentication`)"
- "CapacitorFirebaseAuthentication/Google (from `../../node_modules/@capacitor-firebase/authentication`)"
- "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)" - "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)"
- "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)" - "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
SPEC REPOS:
trunk:
- AppAuth
- FirebaseAppCheckInterop
- FirebaseAuth
- FirebaseCore
- FirebaseCoreInternal
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- PromisesObjC
EXTERNAL SOURCES: EXTERNAL SOURCES:
Capacitor: Capacitor:
:path: "../../node_modules/@capacitor/ios" :path: "../../node_modules/@capacitor/ios"
@@ -26,21 +98,37 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios" :path: "../../node_modules/@capacitor/ios"
CapacitorDialog: CapacitorDialog:
:path: "../../node_modules/@capacitor/dialog" :path: "../../node_modules/@capacitor/dialog"
CapacitorFirebaseAuthentication:
:path: "../../node_modules/@capacitor-firebase/authentication"
CapacitorHaptics: CapacitorHaptics:
:path: "../../node_modules/@capacitor/haptics" :path: "../../node_modules/@capacitor/haptics"
CapacitorKeyboard: CapacitorKeyboard:
:path: "../../node_modules/@capacitor/keyboard" :path: "../../node_modules/@capacitor/keyboard"
CapacitorSplashScreen:
:path: "../../node_modules/@capacitor/splash-screen"
CapacitorStatusBar: CapacitorStatusBar:
:path: "../../node_modules/@capacitor/status-bar" :path: "../../node_modules/@capacitor/status-bar"
SPEC CHECKSUMS: SPEC CHECKSUMS:
AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570
Capacitor: d3d4463573438b9fa65326d1f3549da6f4c21634 Capacitor: d3d4463573438b9fa65326d1f3549da6f4c21634
CapacitorCordova: b1fe6bf1f36974a8e4a9044b342d22d49c0996d6 CapacitorCordova: b1fe6bf1f36974a8e4a9044b342d22d49c0996d6
CapacitorDialog: 01c49f7f4b37e7ad59e38fd317a6e5f006f23cdc CapacitorDialog: 01c49f7f4b37e7ad59e38fd317a6e5f006f23cdc
CapacitorFirebaseAuthentication: f2e3c2a7488b87078025855588670840f93a721e
CapacitorHaptics: 864585542a435bd41eaabf7f30d9ff5ec03024d3 CapacitorHaptics: 864585542a435bd41eaabf7f30d9ff5ec03024d3
CapacitorKeyboard: e628d4e66d621c69e449945ebabded17c5b9c2e8 CapacitorKeyboard: e628d4e66d621c69e449945ebabded17c5b9c2e8
CapacitorSplashScreen: bd2a056394ba0b8807e7bb3e746424f67c426e03
CapacitorStatusBar: 48f2899f6846cc7d8431b251ebfc58e1c10e3d58 CapacitorStatusBar: 48f2899f6846cc7d8431b251ebfc58e1c10e3d58
FirebaseAppCheckInterop: 7d3521f56872cf74a01792c0a095a30e054ff6ae
FirebaseAuth: 28e6fff787467cd15ab51c8c7aa904003b2f57aa
FirebaseCore: d027ff503d37edb78db98429b11f580a24a7df2a
FirebaseCoreInternal: 971029061d326000d65bfdc21f5502c75c8b0893
GoogleSignIn: b232380cf495a429b8095d3178a8d5855b42e842
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae
GTMSessionFetcher: e8647203b65cee28c5f73d0f473d096653945e72
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
PODFILE CHECKSUM: b469cdc64593e190968b9aa15066224f10938107 PODFILE CHECKSUM: 9a19ff50409d024bca91266c62454036ebb27258
COCOAPODS: 1.12.1 COCOAPODS: 1.12.1

337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,20 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint ./src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/", "format": "prettier --write src/",
"local": "npm run build && firebase serve --only hosting", "local": "npm run build && firebase serve --only hosting",
"deploy": "npm run build && firebase deploy --only hosting:contexted-v3" "deploy": "npm run build && firebase deploy --only hosting:contexted-v3"
}, },
"dependencies": { "dependencies": {
"@capacitor-firebase/authentication": "^5.0.0",
"@capacitor/android": "^5.0.4", "@capacitor/android": "^5.0.4",
"@capacitor/core": "^5.0.4", "@capacitor/core": "^5.0.4",
"@capacitor/dialog": "^5.0.2", "@capacitor/dialog": "^5.0.2",
"@capacitor/haptics": "^5.0.2", "@capacitor/haptics": "^5.0.2",
"@capacitor/ios": "^5.0.4", "@capacitor/ios": "^5.0.4",
"@capacitor/keyboard": "^5.0.2", "@capacitor/keyboard": "^5.0.2",
"@capacitor/splash-screen": "^5.0.2",
"@capacitor/status-bar": "^5.0.2", "@capacitor/status-bar": "^5.0.2",
"@ckeditor/ckeditor5-autoformat": "^37.1.0", "@ckeditor/ckeditor5-autoformat": "^37.1.0",
"@ckeditor/ckeditor5-basic-styles": "^37.1.0", "@ckeditor/ckeditor5-basic-styles": "^37.1.0",
@@ -40,7 +42,6 @@
"@vueuse/core": "^10.1.2", "@vueuse/core": "^10.1.2",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"cytoscape": "^3.25.0", "cytoscape": "^3.25.0",
"daisyui": "^3.0.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"dompurify": "^3.0.2", "dompurify": "^3.0.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -52,8 +53,7 @@
"marked": "^4.3.0", "marked": "^4.3.0",
"shortid": "^2.2.16", "shortid": "^2.2.16",
"turndown": "^7.1.2", "turndown": "^7.1.2",
"vue": "^3.3.4", "vue": "^3.3.4"
"vue-virtual-scroller": "^2.0.0-beta.8"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^5.0.4", "@capacitor/cli": "^5.0.4",
@@ -72,6 +72,7 @@
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.4.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"daisyui": "^4.4.19",
"eslint": "^8.40.0", "eslint": "^8.40.0",
"eslint-plugin-vue": "^9.13.0", "eslint-plugin-vue": "^9.13.0",
"firebase-tools": "^12.1.0", "firebase-tools": "^12.1.0",

View File

@@ -2,21 +2,22 @@
import { activeNote, updateNote, notes, activeNotesSource, getNotes } from '@/composables/useNotes' import { activeNote, updateNote, notes, activeNotesSource, getNotes } from '@/composables/useNotes'
import { viewModes, activeViewMode } from '@/composables/useViewMode' import { viewModes, activeViewMode } from '@/composables/useViewMode'
import { import {
getClientKey, getClientKey,
getEncryptionKey, getEncryptionKey,
encryptionKey, encryptionKey,
setClientKey, setClientKey,
passphraseRequired passphraseRequired
} from '@/composables/useEncryption' } from '@/composables/useEncryption'
import { initializeSettings } from '@/composables/useSettings' import { initializeSettings } from '@/composables/useSettings'
import { windowIsMobile } from '@/utils/helpers' import { windowIsMobile } from '@/utils/helpers'
import SideBar from '@/components/SideBar.vue' import SideBar from '@/components/SideBar.vue'
import firebase from 'firebase/compat/app'
import * as firebaseui from 'firebaseui'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import { SplashScreen } from '@capacitor/splash-screen'
initializeSettings() initializeSettings()
onMounted(() => SplashScreen.hide())
const sideBarCollapsed = ref<boolean>(windowIsMobile()) const sideBarCollapsed = ref<boolean>(windowIsMobile())
const { width } = useWindowSize() const { width } = useWindowSize()
@@ -26,33 +27,33 @@ watch(width, () => (sideBarCollapsed.value = windowIsMobile()))
// const ListView = defineAsyncComponent(() => import('@/components/ViewModes/ListView.vue')) // const ListView = defineAsyncComponent(() => import('@/components/ViewModes/ListView.vue'))
// const Mindmap = defineAsyncComponent(() => import('@/components/ViewModes/Mindmap.vue')) // const Mindmap = defineAsyncComponent(() => import('@/components/ViewModes/Mindmap.vue'))
const firebaseAuthUI = // const firebaseAuthUI =
firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(firebase.auth()) // firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(firebase.auth())
provide('firebaseAuthUI', firebaseAuthUI) // provide('firebaseAuthUI', firebaseAuthUI)
watch( watch(
[activeNotesSource, encryptionKey], [activeNotesSource, encryptionKey],
() => { () => {
if (activeNotesSource.value === 'firebase') { if (activeNotesSource.value === 'firebase') {
getClientKey() getClientKey()
getEncryptionKey() getEncryptionKey()
} }
getNotes() getNotes()
}, },
{ immediate: true } { immediate: true }
) )
const passphrase = ref('') const passphrase = ref('')
const passphraseValid = ref<boolean>() const passphraseValid = ref<boolean>()
const submitPassphrase = (close: () => void) => { const submitPassphrase = (close: () => void) => {
const setClientKeyResult = setClientKey(passphrase.value) const setClientKeyResult = setClientKey(passphrase.value)
passphraseValid.value = setClientKeyResult passphraseValid.value = setClientKeyResult
if (passphraseValid.value) close() if (passphraseValid.value) close()
} }
const loading = computed( const loading = computed(
() => notes.value.length === 0 || passphraseRequired.value || !activeNotesSource.value () => notes.value.length === 0 || passphraseRequired.value || !activeNotesSource.value
) )
provide('loading', loading) provide('loading', loading)
@@ -61,90 +62,90 @@ const topBarHeightWithSafeArea = computed(() => `calc(${topBarHeight}px + var(--
</script> </script>
<template> <template>
<TopBar <TopBar
:side-bar-collapsed="sideBarCollapsed" :side-bar-collapsed="sideBarCollapsed"
:height="topBarHeight" :height="topBarHeight"
:style="{ height: topBarHeightWithSafeArea }" :style="{ height: topBarHeightWithSafeArea }"
@toggle-side-bar="sideBarCollapsed = !sideBarCollapsed" @toggle-side-bar="sideBarCollapsed = !sideBarCollapsed"
class="pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]" class="pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]"
/> />
<!-- <div class="absolute bottom-0 left-0 right-0 top-[50px] mx-auto flex flex-grow"> --> <!-- <div class="absolute bottom-0 left-0 right-0 top-[50px] mx-auto flex flex-grow"> -->
<div <div
class="mx-auto flex w-full max-w-app flex-grow pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]" class="mx-auto flex w-full max-w-app flex-grow pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]"
>
<Transition name="sidebar">
<SideBar
:view-modes="viewModes"
:active-view-mode="activeViewMode"
@set-view-mode="(viewMode) => (activeViewMode = viewMode)"
@collapse="(collapse) => (sideBarCollapsed = collapse)"
class="bg-gray-100 px-3 py-6 transition-[width] delay-200 duration-0 max-sm:z-50 max-sm:border-x-[1px] max-sm:py-3 max-sm:transition-transform max-sm:delay-0 max-sm:duration-200"
:style="{ 'margin-top': topBarHeightWithSafeArea }"
v-if="!sideBarCollapsed"
/>
</Transition>
<Transition name="overlay">
<div
class="absolute bottom-0 left-0 right-0 top-0 z-40 cursor-pointer bg-neutral-800 bg-opacity-60 transition-opacity duration-200 sm:hidden"
@click="sideBarCollapsed = true"
v-if="!sideBarCollapsed"
/>
</Transition>
<main
class="transition[margin-left] z-10 mx-auto flex h-full w-full max-w-app flex-col overflow-y-auto border-x-[1px] bg-white pb-[var(--safe-area-bottom)] duration-200 ease-out"
:class="sideBarCollapsed ? 'ml-0' : 'sm:ml-sidebar'"
> >
<div class="flex h-full w-full px-10 py-6 max-sm:px-4 max-sm:py-3"> <Transition name="sidebar">
<template v-if="!loading"> <SideBar
<Note :view-modes="viewModes"
v-if="activeViewMode.name === 'Note' && activeNote" :active-view-mode="activeViewMode"
:key="activeNote.id" @set-view-mode="(viewMode) => (activeViewMode = viewMode)"
:note="activeNote" @collapse="(collapse) => (sideBarCollapsed = collapse)"
class="" class="bg-gray-100 px-3 py-6 transition-[width] delay-200 duration-0 max-sm:z-50 max-sm:border-x-[1px] max-sm:py-3 max-sm:transition-transform max-sm:delay-0 max-sm:duration-200"
@update="(note) => updateNote(note.id, note)" :style="{ 'margin-top': topBarHeightWithSafeArea }"
/> v-if="!sideBarCollapsed"
<ListView v-else-if="activeViewMode.name === 'List'" /> />
<Mindmap v-else-if="activeViewMode.name === 'Mindmap'" /> </Transition>
<Transition name="overlay">
<div
class="absolute bottom-0 left-0 right-0 top-0 z-40 cursor-pointer bg-neutral-800 bg-opacity-60 transition-opacity duration-200 sm:hidden"
@click="sideBarCollapsed = true"
v-if="!sideBarCollapsed"
/>
</Transition>
<main
class="transition[margin-left] z-10 mx-auto flex h-full w-full max-w-app flex-col overflow-y-auto bg-white pb-[var(--safe-area-bottom)] duration-200 ease-out sm:border-x-[1px]"
:class="sideBarCollapsed ? 'ml-0' : 'sm:ml-sidebar'"
>
<div class="flex w-full flex-grow px-10 py-6 max-sm:px-4 max-sm:py-3">
<template v-if="!loading">
<Note
v-if="activeViewMode.name === 'Note' && activeNote"
:key="activeNote.id"
:note="activeNote"
class=""
@update="(note) => updateNote(note.id, note)"
/>
<ListView v-else-if="activeViewMode.name === 'List'" />
<Mindmap v-else-if="activeViewMode.name === 'Mindmap'" />
</template>
<SkeletonNote v-else />
</div>
</main>
</div>
<UIModal :open="passphraseRequired" persistent>
<template #title>Enter your passphrase</template>
<template #default="{ close }">
<div>
Your notes are encrypted. Please enter your encryption key passphrase to decrypt
your cloud notes.
</div>
<form @submit.prevent="submitPassphrase(close)">
<UIInputText
type="password"
class="w-full !max-w-full"
:color="passphraseValid === false ? 'error' : 'regular'"
v-model="passphrase"
></UIInputText>
</form>
<UIAlert color="error" class="mt-4" v-if="passphraseValid === false">
<i class="fa-solid fa-triangle-exclamation"></i>
The passphrase you entered is incorrect.
</UIAlert>
</template> </template>
<SkeletonNote v-else /> <template #actions="{ close }">
</div> <UIButton color="primary" size="sm" @click="submitPassphrase(close)">Submit</UIButton>
</main> </template>
</div> </UIModal>
<UIModal :open="passphraseRequired" :persistent="true">
<template #title>Enter your passphrase</template>
<template #default="{ close }">
<div>
Your notes are encrypted. Please enter your encryption key passphrase to decrypt your cloud
notes.
</div>
<form @submit.prevent="submitPassphrase(close)">
<UIInputText
type="password"
class="w-full !max-w-full"
:color="passphraseValid === false ? 'error' : 'regular'"
v-model="passphrase"
></UIInputText>
</form>
<UIAlert color="error" class="mt-4" v-if="passphraseValid === false">
<i class="fa-solid fa-triangle-exclamation"></i>
The passphrase you entered is incorrect.
</UIAlert>
</template>
<template #actions="{ close }">
<UIButton color="primary" size="sm" @click="submitPassphrase(close)">Submit</UIButton>
</template>
</UIModal>
</template> </template>
<style scoped> <style scoped>
.sidebar-enter-from, .sidebar-enter-from,
.sidebar-leave-to { .sidebar-leave-to {
@apply max-sm:-translate-x-full; @apply max-sm:-translate-x-full;
} }
.overlay-enter-from, .overlay-enter-from,
.overlay-leave-to { .overlay-leave-to {
@apply opacity-0; @apply opacity-0;
} }
main { main {
contain: size layout style; contain: size layout style;
} }
</style> </style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -15,226 +15,228 @@ const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'
const INPUT_EVENT_DEBOUNCE_WAIT = 300 const INPUT_EVENT_DEBOUNCE_WAIT = 300
export interface CKEditorComponentData { export interface CKEditorComponentData {
instance: Editor | null instance: Editor | null
lastEditorData: string | null lastEditorData: string | null
} }
export default defineComponent({ export default defineComponent({
name: 'Ckeditor', name: 'Ckeditor',
model: { model: {
prop: 'modelValue', prop: 'modelValue',
event: 'update:modelValue' event: 'update:modelValue'
},
props: {
editor: {
type: Function as unknown as PropType<{
create(...args: any): Promise<Editor>
}>,
required: true
},
config: {
type: Object as PropType<EditorConfig>,
default: () => ({})
},
modelValue: {
type: String,
default: ''
},
tagName: {
type: String,
default: 'div'
},
disabled: {
type: Boolean,
default: false
},
disableTwoWayDataBinding: {
type: Boolean,
default: false
}
},
emits: [
'ready',
'destroy',
'blur',
'focus',
'input',
'update:modelValue',
'click',
'editorReady',
'contextedLinkAutocomplete',
'contextedKeypress'
],
data(): CKEditorComponentData {
return {
// Don't define it in #props because it produces a warning.
// https://v3.vuejs.org/guide/component-props.html#one-way-data-flow
instance: null,
lastEditorData: null
}
},
watch: {
modelValue(value) {
// Synchronize changes of #modelValue. There are two sources of changes:
//
// External modelValue change ──────╮
// ╰─────> ┏━━━━━━━━━━━┓
// ┃ Component ┃
// ╭─────> ┗━━━━━━━━━━━┛
// Internal data change ──────╯
// (typing, commands, collaboration)
//
// Case 1: If the change was external (via props), the editor data must be synced with
// the component using instance#setData() and it is OK to destroy the selection.
//
// Case 2: If the change is the result of internal data change, the #modelValue is the
// same as this.lastEditorData, which has been cached on #change:data. If we called
// instance#setData() at this point, that would demolish the selection.
//
// To limit the number of instance#setData() which is time-consuming when there is a
// lot of data we make sure:
// * the new modelValue is at least different than the old modelValue (Case 1.)
// * the new modelValue is different than the last internal instance state (Case 2.)
//
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42.
if (this.instance && value !== this.lastEditorData) {
this.instance.data.set(value)
}
}, },
// Synchronize changes of #disabled. props: {
disabled(readOnlyMode) { editor: {
if (readOnlyMode) { type: Function as unknown as PropType<{
this.instance!.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID) create(...args: any): Promise<Editor>
} else { }>,
this.instance!.disableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID) required: true
}
}
},
created() {
const { CKEDITOR_VERSION } = window
if (CKEDITOR_VERSION) {
const [major] = CKEDITOR_VERSION.split('.').map(Number)
if (major < 37) {
console.warn('The <CKEditor> component requires using CKEditor 5 in version 37 or higher.')
}
} else {
console.warn('Cannot find the "CKEDITOR_VERSION" in the "window" scope.')
}
},
mounted() {
// Clone the config first so it never gets mutated (across multiple editor instances).
// https://github.com/ckeditor/ckeditor5-vue/issues/101
const editorConfig: EditorConfig = Object.assign({}, this.config)
if (this.modelValue) {
editorConfig.initialData = this.modelValue
}
this.editor
.create(this.$el, editorConfig)
.then((editor) => {
// Save the reference to the instance for further use.
this.instance = markRaw(editor)
this.setUpEditorEvents()
// Synchronize the editor content. The #modelValue may change while the editor is being created, so the editor content has
// to be synchronized with these potential changes as soon as it is ready.
if (this.modelValue !== editorConfig.initialData) {
editor.data.set(this.modelValue)
}
// Set initial disabled state.
if (this.disabled) {
editor.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
// Let the world know the editor is ready.
this.$emit('ready', editor)
})
.catch((error) => {
console.error(error)
})
},
beforeUnmount() {
if (this.instance) {
this.instance.destroy()
this.instance = null
}
// Note: By the time the editor is destroyed (promise resolved, editor#destroy fired)
// the Vue component will not be able to emit any longer. So emitting #destroy a bit earlier.
this.$emit('destroy', this.instance)
},
methods: {
setUpEditorEvents() {
const editor = this.instance!
this.$emit('editorReady', editor)
// Use the leading edge so the first event in the series is emitted immediately.
// Failing to do so leads to race conditions, for instance, when the component modelValue
// is set twice in a time span shorter than the debounce time.
// See https://github.com/ckeditor/ckeditor5-vue/issues/149.
const emitDebouncedInputEvent = debounce(
(evt) => {
if (this.disableTwoWayDataBinding) {
return
}
// Cache the last editor data. This kind of data is a result of typing,
// editor command execution, collaborative changes to the document, etc.
// This data is compared when the component modelValue changes in a 2-way binding.
const data = (this.lastEditorData = editor.data.get())
// The compatibility with the v-model and general Vue.js concept of inputlike components.
this.$emit('update:modelValue', data, evt, editor)
this.$emit('input', data, evt, editor)
}, },
INPUT_EVENT_DEBOUNCE_WAIT, config: {
{ leading: true } type: Object as PropType<EditorConfig>,
) default: () => ({})
},
modelValue: {
type: String,
default: ''
},
tagName: {
type: String,
default: 'div'
},
disabled: {
type: Boolean,
default: false
},
disableTwoWayDataBinding: {
type: Boolean,
default: false
}
},
// Debounce emitting the #input event. When data is huge, instance#getData() emits: [
// takes a lot of time to execute on every single key press and ruins the UX. 'ready',
// 'destroy',
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42 'blur',
editor.model.document.on('change:data', emitDebouncedInputEvent) 'focus',
'input',
'update:modelValue',
'click',
'editorReady',
'contextedLinkAutocomplete',
'contextedKeypress'
],
editor.editing.view.document.on('focus', (evt) => { data(): CKEditorComponentData {
this.$emit('focus', evt, editor) return {
}) // Don't define it in #props because it produces a warning.
// https://v3.vuejs.org/guide/component-props.html#one-way-data-flow
instance: null,
lastEditorData: null
}
},
editor.editing.view.document.on('blur', (evt) => { watch: {
this.$emit('blur', evt, editor) modelValue(value) {
}) // Synchronize changes of #modelValue. There are two sources of changes:
//
// External modelValue change ──────╮
// ╰─────> ┏━━━━━━━━━━━┓
// ┃ Component ┃
// ╭─────> ┗━━━━━━━━━━━┛
// Internal data change ──────╯
// (typing, commands, collaboration)
//
// Case 1: If the change was external (via props), the editor data must be synced with
// the component using instance#setData() and it is OK to destroy the selection.
//
// Case 2: If the change is the result of internal data change, the #modelValue is the
// same as this.lastEditorData, which has been cached on #change:data. If we called
// instance#setData() at this point, that would demolish the selection.
//
// To limit the number of instance#setData() which is time-consuming when there is a
// lot of data we make sure:
// * the new modelValue is at least different than the old modelValue (Case 1.)
// * the new modelValue is different than the last internal instance state (Case 2.)
//
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42.
if (this.instance && value !== this.lastEditorData) {
this.instance.data.set(value)
}
},
// Custom event // Synchronize changes of #disabled.
editor.editing.view.document.on('click', (evt, data) => { disabled(readOnlyMode) {
this.$emit('click', { evt, data }, editor) if (readOnlyMode) {
}) this.instance!.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
} else {
this.instance!.disableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
}
},
editor.model.document.on('contextedLinkAutocomplete', (_, data) => { created() {
this.$emit('contextedLinkAutocomplete', data) const { CKEDITOR_VERSION } = window
})
editor.model.document.on('contextedKeypress', (_, eventData) => { if (CKEDITOR_VERSION) {
this.$emit('contextedKeypress', eventData) const [major] = CKEDITOR_VERSION.split('.').map(Number)
})
if (major < 37) {
console.warn(
'The <CKEditor> component requires using CKEditor 5 in version 37 or higher.'
)
}
} else {
console.warn('Cannot find the "CKEDITOR_VERSION" in the "window" scope.')
}
},
mounted() {
// Clone the config first so it never gets mutated (across multiple editor instances).
// https://github.com/ckeditor/ckeditor5-vue/issues/101
const editorConfig: EditorConfig = Object.assign({}, this.config)
if (this.modelValue) {
editorConfig.initialData = this.modelValue
}
this.editor
.create(this.$el, editorConfig)
.then((editor) => {
// Save the reference to the instance for further use.
this.instance = markRaw(editor)
this.setUpEditorEvents()
// Synchronize the editor content. The #modelValue may change while the editor is being created, so the editor content has
// to be synchronized with these potential changes as soon as it is ready.
if (this.modelValue !== editorConfig.initialData) {
editor.data.set(this.modelValue)
}
// Set initial disabled state.
if (this.disabled) {
editor.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
// Let the world know the editor is ready.
this.$emit('ready', editor)
})
.catch((error) => {
console.error(error)
})
},
beforeUnmount() {
if (this.instance) {
this.instance.destroy()
this.instance = null
}
// Note: By the time the editor is destroyed (promise resolved, editor#destroy fired)
// the Vue component will not be able to emit any longer. So emitting #destroy a bit earlier.
this.$emit('destroy', this.instance)
},
methods: {
setUpEditorEvents() {
const editor = this.instance!
this.$emit('editorReady', editor)
// Use the leading edge so the first event in the series is emitted immediately.
// Failing to do so leads to race conditions, for instance, when the component modelValue
// is set twice in a time span shorter than the debounce time.
// See https://github.com/ckeditor/ckeditor5-vue/issues/149.
const emitDebouncedInputEvent = debounce(
(evt) => {
if (this.disableTwoWayDataBinding) {
return
}
// Cache the last editor data. This kind of data is a result of typing,
// editor command execution, collaborative changes to the document, etc.
// This data is compared when the component modelValue changes in a 2-way binding.
const data = (this.lastEditorData = editor.data.get())
// The compatibility with the v-model and general Vue.js concept of inputlike components.
this.$emit('update:modelValue', data, evt, editor)
this.$emit('input', data, evt, editor)
},
INPUT_EVENT_DEBOUNCE_WAIT,
{ leading: true }
)
// Debounce emitting the #input event. When data is huge, instance#getData()
// takes a lot of time to execute on every single key press and ruins the UX.
//
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42
editor.model.document.on('change:data', emitDebouncedInputEvent)
editor.editing.view.document.on('focus', (evt) => {
this.$emit('focus', evt, editor)
})
editor.editing.view.document.on('blur', (evt) => {
this.$emit('blur', evt, editor)
})
// Custom event
editor.editing.view.document.on('click', (evt, data) => {
this.$emit('click', { evt, data }, editor)
})
editor.model.document.on('contextedLinkAutocomplete', (_, data) => {
this.$emit('contextedLinkAutocomplete', data)
})
editor.model.document.on('contextedKeypress', (_, eventData) => {
this.$emit('contextedKeypress', eventData)
})
}
},
render() {
return h(this.tagName)
} }
},
render() {
return h(this.tagName)
}
}) })

View File

@@ -6,178 +6,184 @@ import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'
const HIGHLIGHT_CLASS = 'ck-link_selected' const HIGHLIGHT_CLASS = 'ck-link_selected'
export default class ContextedLinkEditing extends Plugin { export default class ContextedLinkEditing extends Plugin {
init() { init() {
this._defineSchema() // ADDED this._defineSchema() // ADDED
this._defineConverters() // ADDED this._defineConverters() // ADDED
this._addContextedKeyHandler() this._addContextedKeyHandler()
const twoStepCaretMovementPlugin = this.editor.plugins.get(TwoStepCaretMovement) const twoStepCaretMovementPlugin = this.editor.plugins.get(TwoStepCaretMovement)
twoStepCaretMovementPlugin.registerAttribute('contextedLink') twoStepCaretMovementPlugin.registerAttribute('contextedLink')
inlineHighlight(this.editor, 'contextedLink', 'a', HIGHLIGHT_CLASS) inlineHighlight(this.editor, 'contextedLink', 'a', HIGHLIGHT_CLASS)
this.editor.commands.add('autocomplete', new AttributeCommand(this.editor, 'autocomplete')) this.editor.commands.add('autocomplete', new AttributeCommand(this.editor, 'autocomplete'))
} }
afterInit() { afterInit() {
this._addAutocomplete() this._addAutocomplete()
} }
_defineSchema() { _defineSchema() {
// ADDED // ADDED
const schema = this.editor.model.schema const schema = this.editor.model.schema
// Extend the text node's schema to accept the abbreviation attribute. // Extend the text node's schema to accept the abbreviation attribute.
schema.extend('$text', { schema.extend('$text', {
allowAttributes: ['contextedLink', 'autocomplete'] allowAttributes: ['contextedLink', 'autocomplete']
})
}
_defineConverters() {
// ADDED
const conversion = this.editor.conversion
// Conversion from a model attribute to a view element.
conversion.for('downcast').attributeToElement({
model: 'contextedLink',
// Callback function provides access to the model attribute value
// and the DowncastWriter.
view: (modelAttributeValue, conversionApi) => {
const { writer } = conversionApi
return writer.createAttributeElement('a', {
'data-contexted-link': modelAttributeValue
}) })
} }
}) _defineConverters() {
conversion.for('upcast').elementToAttribute({ // ADDED
view: { const conversion = this.editor.conversion
name: 'a',
key: 'data-contexted-link'
},
model: {
key: 'contextedLink'
},
converterPriority: 'high'
})
}
_addAutocomplete() {
// Copied from: node_modules/@ckeditor/ckeditor5-autoformat/src/inlineautoformatediting.js
const editor = this.editor
let showAutocomplete = false
editor.model.document.on('change', (_, batch) => {
if (batch.isUndo || !batch.isLocal) return
const model = editor.model
const selection = model.document.selection
// Do nothing if selection is not collapsed.
if (!selection.isCollapsed) return
const changes = Array.from(model.document.differ.getChanges())
const entry = changes[0]
// Typing is represented by only a single change.
if (
changes.length != 1 ||
(entry.type !== 'insert' && entry.type !== 'remove') ||
(entry.name != '$text' && entry.name != 'paragraph') ||
entry.length != 1
) {
return
}
const focus = selection.focus // Conversion from a model attribute to a view element.
const block = focus?.parent conversion.for('downcast').attributeToElement({
if (!block || !focus) return model: 'contextedLink',
const { text, range } = getTextAfterCode( // Callback function provides access to the model attribute value
model.createRange(model.createPositionAt(block, 0), focus), // and the DowncastWriter.
model view: (modelAttributeValue, conversionApi) => {
) const { writer } = conversionApi
const inputText = (text as string).split(']]').slice(-1)[0]
const autocompleteText = (inputText as string).match(/(?<=\[\[).*/g)
const cursorNodes = [focus.textNode, focus.nodeBefore, focus.nodeAfter]
const autocompleteNode: any = cursorNodes.find((node) =>
['contextedLink', 'autocomplete'].some((attribute) => node?.hasAttribute(attribute))
)
if (Boolean(autocompleteText) !== Boolean(autocompleteNode)) { return writer.createAttributeElement('a', {
editor.execute('autocomplete') 'data-contexted-link': modelAttributeValue
} })
const autocompleteActive = ['contextedLink', 'autocomplete'].some((attribute) => {
return editor.model.document.selection.hasAttribute(attribute)
})
showAutocomplete = autocompleteActive
fireAutocompleteEvent(editor, showAutocomplete, autocompleteNode)
const regexFormat = /(\[\[)([^[]+?)(\]\])$/g
let result
const format: Array<number>[] = []
while ((result = regexFormat.exec(text as string)) !== null) {
if (result && result.length < 4) {
break
}
let index = result.index
const { '1': leftDel, '2': content, '3': rightDel } = result
// Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
const found = leftDel + content + rightDel
index += result[0].length - found.length
format.push([index + leftDel.length, index + leftDel.length + content.length])
}
model.enqueueChange((writer) => {
const rangesToFormat = format.map((array) =>
model.createRange(range.start.getShiftedBy(array[0]), range.start.getShiftedBy(array[1]))
)
const validRanges = editor.model.schema.getValidRanges(rangesToFormat, 'contextedLink')
for (const range of validRanges) {
for (const item of range.getItems()) {
if ((item as any).data) {
writer.setAttribute('contextedLink', true, range)
} }
} })
} conversion.for('upcast').elementToAttribute({
}) view: {
}) name: 'a',
} key: 'data-contexted-link'
_addContextedKeyHandler() { },
const editor = this.editor model: {
const viewDocument = editor.editing.view.document key: 'contextedLink'
viewDocument.on( },
'keydown', converterPriority: 'high'
(evt, data) => { })
const { keyCode } = data }
const keyCodesCycle = [38, 40] // Up, Down _addAutocomplete() {
const keyCodesConfirm = [13] // Enter // Copied from: node_modules/@ckeditor/ckeditor5-autoformat/src/inlineautoformatediting.js
const keyCodesCancel = [27] // Escape const editor = this.editor
if (keyCodesCancel.includes(keyCode)) { let showAutocomplete = false
fireAutocompleteEvent(editor, false) editor.model.document.on('change', (_, batch) => {
} if (batch.isUndo || !batch.isLocal) return
const keyCodes = [...keyCodesConfirm, ...keyCodesCycle] const model = editor.model
const selection = editor.model.document.selection const selection = model.document.selection
const selectionInContextedLink = ['contextedLink', 'autocomplete'].some((attribute) => // Do nothing if selection is not collapsed.
selection.hasAttribute(attribute) if (!selection.isCollapsed) return
const changes = Array.from(model.document.differ.getChanges())
const entry = changes[0]
// Typing is represented by only a single change.
if (
changes.length != 1 ||
(entry.type !== 'insert' && entry.type !== 'remove') ||
(entry.name != '$text' && entry.name != 'paragraph') ||
entry.length != 1
) {
return
}
const focus = selection.focus
const block = focus?.parent
if (!block || !focus) return
const { text, range } = getTextAfterCode(
model.createRange(model.createPositionAt(block, 0), focus),
model
)
const inputText = (text as string).split(']]').slice(-1)[0]
const autocompleteText = (inputText as string).match(/(?<=\[\[).*/g)
const cursorNodes = [focus.textNode, focus.nodeBefore, focus.nodeAfter]
const autocompleteNode: any = cursorNodes.find((node) =>
['contextedLink', 'autocomplete'].some((attribute) => node?.hasAttribute(attribute))
)
if (Boolean(autocompleteText) !== Boolean(autocompleteNode)) {
editor.execute('autocomplete')
}
const autocompleteActive = ['contextedLink', 'autocomplete'].some((attribute) => {
return editor.model.document.selection.hasAttribute(attribute)
})
showAutocomplete = autocompleteActive
fireAutocompleteEvent(editor, showAutocomplete, autocompleteNode)
const regexFormat = /(\[\[)([^[]+?)(\]\])$/g
let result
const format: Array<number>[] = []
while ((result = regexFormat.exec(text as string)) !== null) {
if (result && result.length < 4) {
break
}
let index = result.index
const { '1': leftDel, '2': content, '3': rightDel } = result
// Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
const found = leftDel + content + rightDel
index += result[0].length - found.length
format.push([index + leftDel.length, index + leftDel.length + content.length])
}
model.enqueueChange((writer) => {
const rangesToFormat = format.map((array) =>
model.createRange(
range.start.getShiftedBy(array[0]),
range.start.getShiftedBy(array[1])
)
)
const validRanges = editor.model.schema.getValidRanges(
rangesToFormat,
'contextedLink'
)
for (const range of validRanges) {
for (const item of range.getItems()) {
if ((item as any).data) {
writer.setAttribute('contextedLink', true, range)
}
}
}
})
})
}
_addContextedKeyHandler() {
const editor = this.editor
const viewDocument = editor.editing.view.document
viewDocument.on(
'keydown',
(evt, data) => {
const { keyCode } = data
const keyCodesCycle = [38, 40] // Up, Down
const keyCodesConfirm = [13] // Enter
const keyCodesCancel = [27] // Escape
if (keyCodesCancel.includes(keyCode)) {
fireAutocompleteEvent(editor, false)
}
const keyCodes = [...keyCodesConfirm, ...keyCodesCycle]
const selection = editor.model.document.selection
const selectionInContextedLink = ['contextedLink', 'autocomplete'].some(
(attribute) => selection.hasAttribute(attribute)
)
if (selectionInContextedLink && keyCodes.includes(keyCode)) {
if (selection.hasAttribute('contextedLink')) {
const autocompleteNode = [
selection.focus?.nodeBefore,
selection.focus?.textNode,
selection.focus?.nodeAfter
].find((node) => Boolean(node))
fireAutocompleteEvent(editor, true, autocompleteNode)
}
this.editor.model.document.fire('contextedKeypress', { keyCode })
data.preventDefault()
evt.stop()
}
},
{ priority: 'highest' }
) )
if (selectionInContextedLink && keyCodes.includes(keyCode)) { }
if (selection.hasAttribute('contextedLink')) {
const autocompleteNode = [
selection.focus?.nodeBefore,
selection.focus?.textNode,
selection.focus?.nodeAfter
].find((node) => Boolean(node))
fireAutocompleteEvent(editor, true, autocompleteNode)
}
this.editor.model.document.fire('contextedKeypress', { keyCode })
data.preventDefault()
evt.stop()
}
},
{ priority: 'highest' }
)
}
} }
function getNodePosition(editor: any, modelPosition: any) { function getNodePosition(editor: any, modelPosition: any) {
try { try {
const mapper = editor.editing.mapper const mapper = editor.editing.mapper
const viewPosition = mapper.toViewPosition(modelPosition) const viewPosition = mapper.toViewPosition(modelPosition)
const viewRange = editor.editing.view.createRange(viewPosition) const viewRange = editor.editing.view.createRange(viewPosition)
const domConverter = editor.editing.view.domConverter const domConverter = editor.editing.view.domConverter
const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)).pop() const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)).pop()
return rangeRects return rangeRects
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
} }
// function testOutputToRanges(start: any, arrays: any[], model: any) { // function testOutputToRanges(start: any, arrays: any[], model: any) {
@@ -192,40 +198,43 @@ function getNodePosition(editor: any, modelPosition: any) {
// } // }
function getTextAfterCode(range: any, model: any) { function getTextAfterCode(range: any, model: any) {
let start = range.start let start = range.start
const text = Array.from(range.getItems()).reduce((rangeText: any, node: any) => { const text = Array.from(range.getItems()).reduce((rangeText: any, node: any) => {
// Trim text to a last occurrence of an inline element and update range start. // Trim text to a last occurrence of an inline element and update range start.
if (!(node.is('$text') || node.is('$textProxy')) || node.getAttribute('code')) { if (!(node.is('$text') || node.is('$textProxy')) || node.getAttribute('code')) {
start = model.createPositionAfter(node) start = model.createPositionAfter(node)
return '' return ''
} }
return rangeText + node.data return rangeText + node.data
}, '') }, '')
return { text, range: model.createRange(start, range.end) } return { text, range: model.createRange(start, range.end) }
} }
function fireAutocompleteEvent(editor: any, show: boolean, autocompleteNode?: any) { function fireAutocompleteEvent(editor: any, show: boolean, autocompleteNode?: any) {
let event: AutocompleteEvent let event: AutocompleteEvent
if (show && autocompleteNode) { if (show && autocompleteNode) {
const view = editor.editing.view const view = editor.editing.view
const viewPosition = view.document.selection.focus const viewPosition = view.document.selection.focus
const viewNode = viewPosition?.parent.parent const viewNode = viewPosition?.parent.parent
const domElement = viewNode const domElement = viewNode
? (view.domConverter.mapViewToDom(viewNode) as HTMLElement) ? (view.domConverter.mapViewToDom(viewNode) as HTMLElement)
: undefined : undefined
event = { event = {
position: getNodePosition( position: getNodePosition(
editor, editor,
editor.model.createPositionFromPath(autocompleteNode.root, autocompleteNode.getPath()) editor.model.createPositionFromPath(
), autocompleteNode.root,
autocompleteText: autocompleteNode.data, autocompleteNode.getPath()
domElement, )
show: true ),
autocompleteText: autocompleteNode.data,
domElement,
show: true
}
} else {
event = {
show: false
}
} }
} else { editor.model.document.fire('contextedLinkAutocomplete', event)
event = {
show: false
}
}
editor.model.document.fire('contextedLinkAutocomplete', event)
} }

View File

@@ -2,96 +2,133 @@
import firebase from 'firebase/compat/app' import firebase from 'firebase/compat/app'
import 'firebase/compat/auth' import 'firebase/compat/auth'
import 'firebaseui/dist/firebaseui.css' import 'firebaseui/dist/firebaseui.css'
import { import * as firebaseui from 'firebaseui'
getAuth, import { FirebaseAuthentication } from '@capacitor-firebase/authentication'
signInWithRedirect, import { getAuth, GoogleAuthProvider, signInWithCredential } from 'firebase/auth'
GoogleAuthProvider,
GithubAuthProvider,
OAuthProvider
} from 'firebase/auth'
// const props = defineProps<{
// authenticating?: boolean
// }>()
const emit = defineEmits<{ const emit = defineEmits<{
signedIn: [authResult: any] signedIn: [authResult: any]
}>() }>()
const ui: any = inject('firebaseAuthUI') // const ui: any = inject('firebaseAuthUI')
const auth = getAuth()
const firebaseAuthUI = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(auth)
const uiConfig = { const uiConfig = {
signInOptions: [ signInOptions: [
firebase.auth.EmailAuthProvider.PROVIDER_ID firebase.auth.EmailAuthProvider.PROVIDER_ID
// firebase.auth.GoogleAuthProvider.PROVIDER_ID // firebase.auth.GoogleAuthProvider.PROVIDER_ID
], ],
// signInFlow: 'popup', // signInFlow: 'popup',
signInFlow: 'redirect', signInFlow: 'redirect',
callbacks: { callbacks: {
signInSuccessWithAuthResult(authResult: any) { signInSuccessWithAuthResult(authResult: any) {
// var user = authResult.user // var user = authResult.user
// var credential = authResult.credential // var credential = authResult.credential
// var isNewUser = authResult.additionalUserInfo.isNewUser // var isNewUser = authResult.additionalUserInfo.isNewUser
// var providerId = authResult.additionalUserInfo.providerId // var providerId = authResult.additionalUserInfo.providerId
// var operationType = authResult.operationType // var operationType = authResult.operationType
// Do something with the returned AuthResult. // Do something with the returned AuthResult.
// Return type determines whether we continue the redirect // Return type determines whether we continue the redirect
// automatically or whether we leave that to developer to handle. // automatically or whether we leave that to developer to handle.
emit('signedIn', authResult) emit('signedIn', authResult)
return false return false
}, },
signInFailure(error: any) { signInFailure(error: any) {
console.error('Error signing in', error) console.error('Error signing in', error)
}
} }
} // Other config options...
// Other config options...
} }
// onMounted(() => ui.start('#auth', uiConfig)) // onMounted(() => ui.start('#auth', uiConfig))
type AuthProvider = 'google' | 'github' | 'microsoft' interface Provider {
const providers: { [key in AuthProvider]: any | (() => any) } = { name: 'google' | 'microsoft' | 'github'
google: new GoogleAuthProvider(), icon: string
github: () => { signin: () => Promise<void>
const provider = new GithubAuthProvider() // (options?: SignInOptions) => Promise<SignInResult>
provider.addScope('user:email')
return provider
},
microsoft: new OAuthProvider('microsoft.com')
} }
const signIn = (providerName: AuthProvider) => { const providers: Provider[] = [
const auth = getAuth() {
const provider = name: 'google',
typeof providers[providerName] === 'function' icon: 'fa-brands fa-google',
? providers[providerName]() signin: async () => {
: providers[providerName] const result = await FirebaseAuthentication.signInWithGoogle({
signInWithRedirect(auth, provider) mode: 'redirect'
})
const credential = GoogleAuthProvider.credential(result.credential?.idToken)
await signInWithCredential(auth, credential)
}
}
// {
// name: 'microsoft',
// icon: 'fa-brands fa-microsoft',
// signin: async () => {
// const result = await FirebaseAuthentication.signInWithMicrosoft({
// mode: 'redirect'
// })
// const provider = new OAuthProvider('microsoft.com')
// const credential = provider.credential({
// idToken: result.credential?.idToken,
// rawNonce: result.credential?.nonce
// })
// await signInWithCredential(auth, credential)
// }
// },
// {
// name: 'github',
// icon: 'fa-brands fa-github',
// signin: async () => {
// const result = await FirebaseAuthentication.signInWithGithub({
// mode: 'redirect'
// })
// const provider = new OAuthProvider('github.com')
// const credential = provider.credential({
// idToken: result.credential?.idToken,
// rawNonce: result.credential?.nonce
// })
// await signInWithCredential(auth, credential)
// }
// }
]
// type Provider = (typeof providers)[number]
const signInWithProvider = async (provider: Provider) => {
provider.signin()
} }
const authenticatingWithEmail = ref(false) const signingInWithEmail = ref(false)
const signInWithEmail = () => { const signInWithEmail = () => {
authenticatingWithEmail.value = true firebaseAuthUI.start('#auth', uiConfig)
ui.start('#auth', uiConfig) signingInWithEmail.value = true
} }
</script> </script>
<template> <template>
<div class="flex flex-col items-center" v-if="!authenticatingWithEmail"> <div class="space-y-2">
<UIButton class="mx-auto w-[200px] bg-red-500 text-center" @click="signIn('google')"> <template v-if="!signingInWithEmail">
Sign in with Google <UIButton
</UIButton> class="mx-auto !block w-[225px] max-sm:w-full"
<UIButton class="mx-auto w-[200px] bg-blue-300 text-center" @click="signIn('github')"> size="sm"
Sign in with Github @click="signInWithProvider(provider)"
</UIButton> v-for="provider in providers"
<UIButton class="mx-auto w-[200px] bg-blue-300 text-center" @click="signIn('microsoft')"> :key="provider.name"
Sign in with Microsoft >
</UIButton> <i class="fa-fw mr-2" :class="provider.icon"></i>
<UIButton class="mx-auto w-[200px] bg-blue-300 text-center" @click="signInWithEmail"> Sign in with {{ provider.name }}
Sign in with e-mail </UIButton>
</UIButton> <UIButton
</div> class="mx-auto !block w-[225px] max-sm:w-full"
<div id="auth"></div> size="sm"
<!-- <progress @click="signInWithEmail"
>
<i class="fa-fw fa-regular fa-envelope mr-2"></i>
Sign in with email
</UIButton>
</template>
<div id="auth"></div>
<!-- <progress
v-show="props.authenticating" v-show="props.authenticating"
class="dui-progress dui-progress-primary w-full" class="dui-progress dui-progress-primary w-full"
></progress> --> ></progress> -->
</div>
</template> </template>

View File

@@ -2,60 +2,60 @@
import { notes, findNotesByByTitle, activeNote } from '@/composables/useNotes' import { notes, findNotesByByTitle, activeNote } from '@/composables/useNotes'
const props = defineProps<{ const props = defineProps<{
autocompleteText: string autocompleteText: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
createLink: [title: string] createLink: [title: string]
}>() }>()
const results = computed<Note[]>(() => { const results = computed<Note[]>(() => {
return (props.autocompleteText ? findNotesByByTitle(props.autocompleteText) : notes.value) return (props.autocompleteText ? findNotesByByTitle(props.autocompleteText) : notes.value)
.filter((note) => note.id !== activeNote.value?.id) .filter((note) => note.id !== activeNote.value?.id)
.slice(0, 10) .slice(0, 10)
}) })
const activeResult = ref<Note>() const activeResult = ref<Note>()
const changeActiveResult = (direction: number) => { const changeActiveResult = (direction: number) => {
const index = results.value.findIndex((note) => note.id === activeResult.value?.id) const index = results.value.findIndex((note) => note.id === activeResult.value?.id)
const newIndex = const newIndex =
index + direction < results.value.length index + direction < results.value.length
? index + direction >= -1 ? index + direction >= -1
? index + direction ? index + direction
: results.value.length - 1 : results.value.length - 1
: -1 : -1
activeResult.value = newIndex >= 0 ? results.value[newIndex] : undefined activeResult.value = newIndex >= 0 ? results.value[newIndex] : undefined
} }
const handleKeypress = (event: { [key: string]: number }) => { const handleKeypress = (event: { [key: string]: number }) => {
const keyCode = event.keyCode const keyCode = event.keyCode
const keyCodes = { const keyCodes = {
cycle: [38, 40], cycle: [38, 40],
confirm: [13] confirm: [13]
} }
if (keyCodes.cycle.includes(keyCode)) { if (keyCodes.cycle.includes(keyCode)) {
const direction = keyCode === 38 ? -1 : 1 const direction = keyCode === 38 ? -1 : 1
changeActiveResult(direction) changeActiveResult(direction)
} else if (keyCodes.confirm.includes(keyCode)) { } else if (keyCodes.confirm.includes(keyCode)) {
const contextedLink = activeResult.value ? activeResult.value.title : props.autocompleteText const contextedLink = activeResult.value ? activeResult.value.title : props.autocompleteText
emit('createLink', contextedLink) emit('createLink', contextedLink)
} }
} }
defineExpose({ handleKeypress }) defineExpose({ handleKeypress })
</script> </script>
<template> <template>
<UIMenu class="border-[1px] p-2 text-[0.875rem] text-black shadow-md" :compact="true"> <UIMenu class="border-[1px] p-2 text-[0.875rem] text-black shadow-md" compact>
<UIMenuItem :active="!activeResult" @click="emit('createLink', props.autocompleteText)"> <UIMenuItem :active="!activeResult" @click="emit('createLink', props.autocompleteText)">
<span class="flex-grow">{{ props.autocompleteText }}</span> <span class="flex-grow">{{ props.autocompleteText }}</span>
<i class="fas fa-plus-circle ml-auto text-white" /> <i class="fas fa-plus-circle ml-auto text-white" />
</UIMenuItem> </UIMenuItem>
<SearchResult <SearchResult
v-for="result in results" v-for="result in results"
:key="result.id" :key="result.id"
:result="result" :result="result"
:active-result="activeResult" :active-result="activeResult"
@go-to-note="emit('createLink', result.title)" @go-to-note="emit('createLink', result.title)"
/> />
</UIMenu> </UIMenu>
</template> </template>

View File

@@ -15,12 +15,11 @@ import ContextedPlugin from '@/ckeditor/ContextedPlugin'
import { mdToHtml, htmlToMd } from '@/utils/markdown' import { mdToHtml, htmlToMd } from '@/utils/markdown'
import { getNoteByTitle, setActiveNote, addNote } from '@/composables/useNotes' import { getNoteByTitle, setActiveNote, addNote } from '@/composables/useNotes'
import Autocomplete from '@/components/Note/Autocomplete.vue' import Autocomplete from '@/components/Note/Autocomplete.vue'
import { Haptics, ImpactStyle } from '@capacitor/haptics' import { vibrate } from '@/composables/useHaptics'
const props = defineProps<{ note: Note }>() const props = defineProps<{ note: Note }>()
const emit = defineEmits<{ const emit = defineEmits<{
update: [mdText: string] update: [mdText: string]
}>() }>()
const html = mdToHtml(props.note.content) const html = mdToHtml(props.note.content)
@@ -28,34 +27,34 @@ const html = mdToHtml(props.note.content)
const editor = BalloonEditor const editor = BalloonEditor
const editorData = ref<string>(html) const editorData = ref<string>(html)
const editorConfig = { const editorConfig = {
plugins: [ plugins: [
EssentialsPlugin, EssentialsPlugin,
BoldPlugin, BoldPlugin,
ItalicPlugin, ItalicPlugin,
UnderlinePlugin, UnderlinePlugin,
StrikethroughPlugin, StrikethroughPlugin,
LinkPlugin, LinkPlugin,
HeadingPlugin, HeadingPlugin,
ParagraphPlugin, ParagraphPlugin,
ListPlugin, ListPlugin,
AutoformatPlugin, AutoformatPlugin,
ContextedPlugin ContextedPlugin
], ],
toolbar: { toolbar: {
items: [ items: [
'bold', 'bold',
'italic', 'italic',
'underline', 'underline',
'strikethrough', 'strikethrough',
'link', 'link',
'undo', 'undo',
'redo', 'redo',
'heading', 'heading',
'bulletedList', 'bulletedList',
'numberedList' 'numberedList'
] ]
}, },
placeholder: 'Click here to start typing...' placeholder: 'Click here to start typing...'
} }
const editorElement = ref<HTMLInputElement | null>(null) const editorElement = ref<HTMLInputElement | null>(null)
@@ -63,12 +62,12 @@ watch(editorData, () => emit('update', htmlToMd(editorData.value)))
let editorInstance: any let editorInstance: any
const handleClick = async ({ data }: { data: any }) => { const handleClick = async ({ data }: { data: any }) => {
if (!data.domTarget.hasAttribute('data-contexted-link')) return if (!data.domTarget.hasAttribute('data-contexted-link')) return
const noteTitle = data.domTarget.textContent as string const noteTitle = data.domTarget.textContent as string
let note: BaseNote | Note | undefined = getNoteByTitle(noteTitle) let note: BaseNote | Note | undefined = getNoteByTitle(noteTitle)
if (!note) note = addNote(noteTitle, '') if (!note) note = addNote(noteTitle, '')
setActiveNote(note.id) setActiveNote(note.id)
await Haptics.impact({ style: ImpactStyle.Light }) await vibrate()
} }
const autocompleteRef = ref<InstanceType<typeof Autocomplete> | null>(null) const autocompleteRef = ref<InstanceType<typeof Autocomplete> | null>(null)
@@ -78,108 +77,108 @@ const autocompleteText = ref<string>('')
const autocompleteReverse = ref<boolean>(false) const autocompleteReverse = ref<boolean>(false)
const handleAutocomplete = async (event: AutocompleteEvent) => { const handleAutocomplete = async (event: AutocompleteEvent) => {
const position = event.position const position = event.position
if (position && editorElement.value) { if (position && editorElement.value) {
const rect: any = editorElement.value?.getBoundingClientRect() const rect: any = editorElement.value?.getBoundingClientRect()
const lineHeight = parseFloat( const lineHeight = parseFloat(
window.getComputedStyle(event.domElement || editorElement.value).lineHeight window.getComputedStyle(event.domElement || editorElement.value).lineHeight
) )
autocompleteStyle.value = { autocompleteStyle.value = {
top: `${position.top - rect.top + lineHeight}px`, top: `${position.top - rect.top + lineHeight}px`,
left: `${position.left - rect.left}px` left: `${position.left - rect.left}px`
}
} }
} autocompleteText.value = event.autocompleteText || ''
autocompleteText.value = event.autocompleteText || '' showAutocomplete.value = event.show
showAutocomplete.value = event.show await nextTick()
await nextTick() const autocompleteElem = autocompleteRef.value?.$el
const autocompleteElem = autocompleteRef.value?.$el const autocompleteRect = autocompleteRef.value?.$el.getBoundingClientRect()
const autocompleteRect = autocompleteRef.value?.$el.getBoundingClientRect() const editorRect = editorElement.value?.getBoundingClientRect()
const editorRect = editorElement.value?.getBoundingClientRect() if (
if ( autocompleteElem &&
autocompleteElem && autocompleteRect &&
autocompleteRect && editorRect &&
editorRect && autocompleteRect.bottom > editorRect.bottom
autocompleteRect.bottom > editorRect.bottom ) {
) { const autocompleteHeight = parseFloat(window.getComputedStyle(autocompleteElem).height)
const autocompleteHeight = parseFloat(window.getComputedStyle(autocompleteElem).height) autocompleteStyle.value = {
autocompleteStyle.value = { ...autocompleteStyle.value,
...autocompleteStyle.value, top: `${position.top - editorRect.top - autocompleteHeight}px`
top: `${position.top - editorRect.top - autocompleteHeight}px` }
autocompleteReverse.value = true
} else {
autocompleteReverse.value = false
} }
autocompleteReverse.value = true
} else {
autocompleteReverse.value = false
}
} }
const handleContextedKeypress = (event: any) => { const handleContextedKeypress = (event: any) => {
if (autocompleteRef.value) autocompleteRef.value.handleKeypress(event) if (autocompleteRef.value) autocompleteRef.value.handleKeypress(event)
} }
const createLink = (link: string) => { const createLink = (link: string) => {
if (!editor) return if (!editor) return
const model = editorInstance.model const model = editorInstance.model
const getPosition = () => model.document.selection.anchor const getPosition = () => model.document.selection.anchor
// const getNodes = () => { // const getNodes = () => {
// const nodes = [ // const nodes = [
// getPosition().nodeBefore, // getPosition().nodeBefore,
// getPosition().nodeAfter, // getPosition().nodeAfter,
// getPosition().textNnode, // getPosition().textNnode,
// ] // ]
// return nodes.map((node) => ({ // return nodes.map((node) => ({
// data: node?.data, // data: node?.data,
// attrs: Array.from(node?.getAttributes() || []), // attrs: Array.from(node?.getAttributes() || []),
// })) // }))
// } // }
// console.log(getNodes()) // console.log(getNodes())
let nodeToRemove: any let nodeToRemove: any
if (getPosition().nodeBefore?.hasAttribute('autocomplete')) { if (getPosition().nodeBefore?.hasAttribute('autocomplete')) {
// Insert new link // Insert new link
nodeToRemove = getPosition().nodeBefore nodeToRemove = getPosition().nodeBefore
} else if (getPosition().nodeBefore?.hasAttribute('contextedLink')) { } else if (getPosition().nodeBefore?.hasAttribute('contextedLink')) {
// Update existing link from end of existing link (backspace) // Update existing link from end of existing link (backspace)
nodeToRemove = getPosition().nodeBefore nodeToRemove = getPosition().nodeBefore
} else if (getPosition().textNode?.hasAttribute('contextedLink')) { } else if (getPosition().textNode?.hasAttribute('contextedLink')) {
// Update existing link from middle of existing link // Update existing link from middle of existing link
nodeToRemove = getPosition().textNode nodeToRemove = getPosition().textNode
} else if (getPosition().nodeAfter?.hasAttribute('contextedLink')) { } else if (getPosition().nodeAfter?.hasAttribute('contextedLink')) {
// Update existing link from beginning (delete) // Update existing link from beginning (delete)
nodeToRemove = getPosition().nodeAfter nodeToRemove = getPosition().nodeAfter
} }
model.change((writer: any) => { model.change((writer: any) => {
if (nodeToRemove) writer.remove(nodeToRemove) if (nodeToRemove) writer.remove(nodeToRemove)
writer.insertText(link, { contextedLink: true }, getPosition(), 'after') writer.insertText(link, { contextedLink: true }, getPosition(), 'after')
model.enqueueChange((writer: any) => { model.enqueueChange((writer: any) => {
const nodeAfter = getPosition().nodeAfter const nodeAfter = getPosition().nodeAfter
if (!nodeAfter || (nodeAfter && !nodeAfter.data.startsWith(']]'))) { if (!nodeAfter || (nodeAfter && !nodeAfter.data.startsWith(']]'))) {
writer.insertText(']]', model.document.selection.getFirstPosition()) writer.insertText(']]', model.document.selection.getFirstPosition())
} }
})
showAutocomplete.value = false
}) })
showAutocomplete.value = false
})
} }
</script> </script>
<template> <template>
<div class="relative" ref="editorElement"> <div class="relative" ref="editorElement">
<CKEditor <CKEditor
class="w-full flex-grow text-[110%] font-light" class="w-full flex-grow text-[110%] font-light"
:editor="editor" :editor="editor"
v-model="editorData" v-model="editorData"
:config="editorConfig" :config="editorConfig"
@editor-ready="(editor) => (editorInstance = editor)" @editor-ready="(editor) => (editorInstance = editor)"
@click="handleClick" @click="handleClick"
@contexted-link-autocomplete="handleAutocomplete" @contexted-link-autocomplete="handleAutocomplete"
@contexted-keypress="handleContextedKeypress" @contexted-keypress="handleContextedKeypress"
></CKEditor> ></CKEditor>
<Autocomplete <Autocomplete
v-if="showAutocomplete" v-if="showAutocomplete"
ref="autocompleteRef" ref="autocompleteRef"
:autocomplete-text="autocompleteText" :autocomplete-text="autocompleteText"
:style="autocompleteStyle" :style="autocompleteStyle"
@create-link="createLink" @create-link="createLink"
class="absolute w-[300px]" class="absolute w-[300px]"
:class="autocompleteReverse && 'flex-col-reverse'" :class="autocompleteReverse && 'flex-col-reverse'"
/> />
</div> </div>
</template> </template>

View File

@@ -1,22 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { setActiveNote } from '@/composables/useNotes' import { setActiveNote } from '@/composables/useNotes'
const props = defineProps<{ const props = defineProps<{
references: Note[] references: Note[]
}>() }>()
</script> </script>
<template> <template>
<UIMenu class="mt-3 rounded-xl border-[1px] px-3 py-3" v-if="props.references.length > 0"> <UIMenu class="mt-3 rounded-xl border-[1px] px-3 py-3" v-if="props.references.length > 0">
<UIMenuItem :title="true"> <UIMenuItem title>
<span>References</span> <span>References</span>
<UIBadge variant="outline" class="ml-2">{{ props.references.length }}</UIBadge> <UIBadge variant="outline" class="ml-2">{{ props.references.length }}</UIBadge>
</UIMenuItem> </UIMenuItem>
<UIMenuItem <UIMenuItem
v-for="reference in props.references" v-for="reference in props.references"
:key="reference.id" :key="reference.id"
@click="setActiveNote(reference.id)" @click="setActiveNote(reference.id)"
> >
<i class="far fa-file-alt fa-fw" /> <i class="far fa-file-alt fa-fw" />
{{ reference.title }} {{ reference.title }}
</UIMenuItem> </UIMenuItem>
</UIMenu> </UIMenu>
</template> </template>

View File

@@ -2,77 +2,87 @@
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import type { ConfirmOptions } from '@capacitor/dialog' import type { ConfirmOptions } from '@capacitor/dialog'
import { vibrate } from '@/composables/useHaptics'
const props = defineProps<{ const props = defineProps<{
note: Note note: Note
}>() }>()
type ActionKey = 'delete' | 'setRoot' type ActionKey = 'delete' | 'setRoot'
interface ModalOptions { interface ModalOptions {
key: ActionKey key: ActionKey
icon: string icon: string
confirmOptions: ConfirmOptions confirmOptions: ConfirmOptions
} }
const confirmModals: ModalOptions[] = [ const confirmModals: ModalOptions[] = [
{ {
key: 'delete', key: 'delete',
icon: 'fas fa-fw fa-trash', icon: 'fas fa-fw fa-trash',
confirmOptions: { confirmOptions: {
title: 'Delete note', title: 'Delete note',
message: 'Are you sure you want to delete this note?', message: 'Are you sure you want to delete this note?',
okButtonTitle: 'Delete note' okButtonTitle: 'Delete note'
}
},
{
key: 'setRoot',
icon: 'fas fa-fw fa-sitemap',
confirmOptions: {
title: 'Set root note',
message: 'Are you sure you want to set this note as root note?',
okButtonTitle: 'Set note as root note'
}
} }
},
{
key: 'setRoot',
icon: 'fas fa-fw fa-sitemap',
confirmOptions: {
title: 'Set root note',
message: 'Are you sure you want to set this note as root note?',
okButtonTitle: 'Set note as root note'
}
}
] ]
const emit = defineEmits<{ const emit = defineEmits<{
execute: [actionType: ActionKey, close?: () => void] execute: [actionType: ActionKey, close?: () => Promise<void>]
}>() }>()
const openModal = async (open: () => void, modal: ModalOptions) => { const openModal = async (open: () => void, modal: ModalOptions) => {
if (['android', 'ios'].includes(Capacitor.getPlatform())) { if (['android', 'ios'].includes(Capacitor.getPlatform())) {
const { value: confirmed } = await Dialog.confirm(modal.confirmOptions) const { value: confirmed } = await Dialog.confirm(modal.confirmOptions)
if (confirmed) emit('execute', modal.key) if (confirmed) emit('execute', modal.key)
} else { } else {
open() open()
} }
} }
</script> </script>
<template> <template>
<div class="mb-2 flex items-center space-x-2"> <div class="mb-2 flex items-center space-x-2">
<h1 class="flex flex-grow items-center rounded-md text-3xl font-semibold hover:bg-gray-200"> <h1 class="flex flex-grow items-center rounded-md text-3xl font-semibold hover:bg-gray-200">
<slot name="title"></slot> <slot name="title"></slot>
</h1> </h1>
<UIButtonGroup class="flex items-center" v-if="!props.note.isRoot"> <UIButtonGroup class="flex items-center" v-if="!props.note.isRoot">
<UIModal v-for="confirmModal in confirmModals" :key="confirmModal.key"> <UIModal v-for="confirmModal in confirmModals" :key="confirmModal.key">
<template #activator="{ open }"> <template #activator="{ open }">
<UIButton size="sm" @click="openModal(open, confirmModal)" :join="true"> <UIButton
<i :class="confirmModal.icon" /> size="sm"
</UIButton> @click="openModal(open, confirmModal)"
</template> @mousedown="vibrate"
<template #title> join
<i class="mr-2" :class="confirmModal.icon" /> >
{{ confirmModal.confirmOptions.title }} <i :class="confirmModal.icon" />
</template> </UIButton>
<template #default>{{ confirmModal.confirmOptions.message }}</template> </template>
<template #actions="{ close }"> <template #title>
<UIButton size="sm" @click="close">Cancel</UIButton> <i class="mr-2" :class="confirmModal.icon" />
<UIButton size="sm" color="primary" @click="emit('execute', confirmModal.key, close)"> {{ confirmModal.confirmOptions.title }}
{{ confirmModal.confirmOptions.okButtonTitle }} </template>
</UIButton> <template #default>{{ confirmModal.confirmOptions.message }}</template>
</template> <template #actions="{ close }">
</UIModal> <UIButton size="sm" @click="close">Cancel</UIButton>
</UIButtonGroup> <UIButton
</div> size="sm"
color="primary"
@click="emit('execute', confirmModal.key, close)"
>
{{ confirmModal.confirmOptions.okButtonTitle }}
</UIButton>
</template>
</UIModal>
</UIButtonGroup>
</div>
</template> </template>

View File

@@ -3,84 +3,85 @@ import { notes, findNotes, setActiveNote } from '@/composables/useNotes'
import SearchResult from '@/components/Search/SearchResult.vue' import SearchResult from '@/components/Search/SearchResult.vue'
const emit = defineEmits<{ const emit = defineEmits<{
active: [active: boolean] active: [active: boolean]
}>() }>()
const active = ref<boolean>(false) const active = ref<boolean>(false)
watch(active, () => { watch(active, () => {
if (!active.value) { if (!active.value) {
query.value = '' query.value = ''
activeResult.value = undefined activeResult.value = undefined
} }
emit('active', active.value) emit('active', active.value)
}) })
const query = ref<string>('') const query = ref<string>('')
const results = computed<Note[]>(() => { const results = computed<Note[]>(() => {
return query.value ? findNotes(query.value) : notes.value return query.value ? findNotes(query.value) : notes.value
}) })
const goToNote = (note: Note) => { const goToNote = (note: Note) => {
setActiveNote(note.id) setActiveNote(note.id)
active.value = false active.value = false
if (queryElem.value) queryElem.value.blur() if (queryElem.value) queryElem.value.blur()
} }
const queryElem = ref<HTMLInputElement | null>(null) const queryElem = ref<HTMLInputElement | null>(null)
const activeResult = ref<Note>() const activeResult = ref<Note>()
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
const code = event.code const code = event.code
if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code)) { if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code)) {
let index = results.value.findIndex((note) => note.id === activeResult.value?.id) let index = results.value.findIndex((note) => note.id === activeResult.value?.id)
if (['ArrowDown', 'Tab'].includes(code)) { if (['ArrowDown', 'Tab'].includes(code)) {
index++ index++
} else if (['ArrowUp'].includes(code)) { } else if (['ArrowUp'].includes(code)) {
index-- index--
}
if (index + 1 > results.value.length) index = index - results.value.length
if (index < 0) index = results.value.length - 1
activeResult.value = results.value[index]
const element = resultsRefs.value[index].$el
if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code))
element.scrollIntoView({ block: 'nearest' })
} else if (code === 'Enter' && activeResult.value) {
goToNote(activeResult.value)
} else if (code === 'Escape' && queryElem.value) {
queryElem.value.blur()
} }
if (index + 1 > results.value.length) index = index - results.value.length
if (index < 0) index = results.value.length - 1
activeResult.value = results.value[index]
const element = resultsRefs.value[index].$el
if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code)) element.scrollIntoView({ block: 'nearest' })
} else if (code === 'Enter' && activeResult.value) {
goToNote(activeResult.value)
} else if (code === 'Escape' && queryElem.value) {
queryElem.value.blur()
}
} }
const resultsRefs = ref<InstanceType<typeof SearchResult>[]>([]) const resultsRefs = ref<InstanceType<typeof SearchResult>[]>([])
</script> </script>
<template> <template>
<div id="search-container" class="relative h-full flex-grow"> <div id="search-container" class="relative h-full flex-grow">
<input <input
type="text" type="text"
placeholder="Search for notes" placeholder="Search for notes"
class="h-full w-full rounded border-0 bg-white/10 px-2 text-white outline-none placeholder:text-white focus:bg-white focus:text-black" class="h-full w-full rounded border-0 bg-white/10 px-2 text-white outline-none placeholder:text-white focus:bg-white focus:text-black"
@focus="active = true" @focus="active = true"
@mousedown="active = true" @mousedown="active = true"
@blur="active = false" @blur="active = false"
v-model="query" v-model="query"
ref="queryElem" ref="queryElem"
@keydown="handleKeydown" @keydown="handleKeydown"
/> />
<div class="z-1000 absolute left-0 right-0 top-[100%]" v-if="active"> <div class="z-1000 absolute left-0 right-0 top-[100%]" v-if="active">
<UIMenu :compact="true" class="mt-1 w-full rounded-md bg-base-100 p-2 text-black shadow"> <UIMenu compact class="mt-1 w-full rounded-md bg-base-100 p-2 text-black shadow">
<div class="max-h-[320px] w-full overflow-y-auto"> <div class="max-h-[320px] w-full overflow-y-auto">
<template v-if="results.length > 0"> <template v-if="results.length > 0">
<SearchResult <SearchResult
v-for="result in results" v-for="result in results"
:key="result.id" :key="result.id"
:result="result" :result="result"
:active-result="activeResult" :active-result="activeResult"
@go-to-note="goToNote(result)" @go-to-note="goToNote(result)"
ref="resultsRefs" ref="resultsRefs"
/> />
</template> </template>
<UIMenuItem :compact="true" v-else>No notes found</UIMenuItem> <UIMenuItem :compact="true" v-else>No notes found</UIMenuItem>
</div>
</UIMenu>
</div> </div>
</UIMenu>
</div> </div>
</div>
</template> </template>

View File

@@ -3,28 +3,28 @@ import { activeNote } from '@/composables/useNotes'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const props = defineProps<{ const props = defineProps<{
result: Note result: Note
activeResult?: Note activeResult?: Note
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
goToNote: [element: HTMLElement | null] goToNote: [element: HTMLElement | null]
}>() }>()
const element = ref<HTMLElement | null>(null) const element = ref<HTMLElement | null>(null)
</script> </script>
<template> <template>
<UIMenuItem <UIMenuItem
class="flex w-full items-center" class="flex w-full items-center"
@click.stop.prevent="() => emit('goToNote', element)" @click.stop.prevent="() => emit('goToNote', element)"
@mousedown.prevent @mousedown.prevent
:disabled="activeNote?.id === result.id" :disabled="activeNote?.id === result.id"
:active="props.activeResult?.id === result.id" :active="props.activeResult?.id === result.id"
> >
<UIBadge size="sm" variant="ghost" class="mr-0.5" v-if="activeNote?.id === result.id"> <UIBadge size="sm" variant="ghost" class="mr-0.5" v-if="activeNote?.id === result.id">
current current
</UIBadge> </UIBadge>
<span class="flex-grow truncate">{{ result.title }}</span> <span class="flex-grow truncate">{{ result.title }}</span>
<span class="whitespace-nowrap">{{ formatDate(result.modified) }}</span> <span class="whitespace-nowrap">{{ formatDate(result.modified) }}</span>
</UIMenuItem> </UIMenuItem>
</template> </template>

View File

@@ -6,80 +6,80 @@ import { activeViewMode } from '@/composables/useViewMode'
const loading = inject<boolean>('loading') const loading = inject<boolean>('loading')
const props = defineProps<{ const props = defineProps<{
viewModes: ViewMode[] viewModes: ViewMode[]
activeViewMode: ViewMode activeViewMode: ViewMode
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
setViewMode: [viewMode: ViewMode] setViewMode: [viewMode: ViewMode]
collapse: [collapse: boolean] collapse: [collapse: boolean]
}>() }>()
const setActiveNote = (noteId: string | undefined) => { const setActiveNote = (noteId: string | undefined) => {
emit('collapse', windowIsMobile()) emit('collapse', windowIsMobile())
baseSetActiveNote(noteId) baseSetActiveNote(noteId)
} }
const setViewMode = (viewMode: ViewMode) => { const setViewMode = (viewMode: ViewMode) => {
emit('collapse', windowIsMobile()) emit('collapse', windowIsMobile())
emit('setViewMode', viewMode) emit('setViewMode', viewMode)
} }
</script> </script>
<template> <template>
<div <div
id="sidebar" id="sidebar"
class="fixed bottom-0 top-0 flex flex-col gap-4 overflow-y-auto px-2 py-3 text-[90%] max-sm:w-sidebar-mobile max-sm:gap-6 max-sm:text-[110%] sm:w-sidebar" class="fixed bottom-0 top-0 flex flex-col gap-4 overflow-y-auto px-2 py-3 text-[90%] max-sm:w-sidebar-mobile max-sm:gap-6 max-sm:text-[110%] sm:w-sidebar"
> >
<SideBarMenu> <SideBarMenu>
<template #header>Root note</template> <template #header>Root note</template>
<template #items> <template #items>
<SideBarMenuItem <SideBarMenuItem
icon="fas fa-fw fa-home" icon="fas fa-fw fa-home"
@click="setActiveNote(rootNote?.id)" @click="setActiveNote(rootNote?.id)"
:title="rootNote?.title" :title="rootNote?.title"
v-if="!loading" v-if="!loading"
> >
{{ rootNote?.title }} {{ rootNote?.title }}
</SideBarMenuItem> </SideBarMenuItem>
<SkeletonSidebarItem v-else /> <SkeletonSidebarItem v-else />
</template> </template>
</SideBarMenu> </SideBarMenu>
<SideBarMenu> <SideBarMenu>
<template #header>View mode</template> <template #header>View mode</template>
<template #items> <template #items>
<template v-if="!loading"> <template v-if="!loading">
<SideBarMenuItem <SideBarMenuItem
v-for="viewMode in props.viewModes" v-for="viewMode in props.viewModes"
:key="viewMode.name" :key="viewMode.name"
:icon="viewMode.icon" :icon="viewMode.icon"
:active="viewMode.name === activeViewMode.name" :active="viewMode.name === activeViewMode.name"
@click="setViewMode(viewMode)" @click="setViewMode(viewMode)"
> >
{{ viewMode.name }} {{ viewMode.name }}
</SideBarMenuItem> </SideBarMenuItem>
</template> </template>
<SkeletonSidebarItem :n="3" v-else /> <SkeletonSidebarItem :n="3" v-else />
</template> </template>
</SideBarMenu> </SideBarMenu>
<SideBarMenu> <SideBarMenu>
<template #header> <template #header>
<i class="far fa-clock fa-fw mr-2" /> <i class="far fa-clock fa-fw mr-2" />
Recent notes Recent notes
</template> </template>
<template #items> <template #items>
<template v-if="!loading"> <template v-if="!loading">
<SideBarMenuItem <SideBarMenuItem
v-for="note in notes.slice(-5)" v-for="note in notes.slice(-5)"
:key="note.id" :key="note.id"
icon="far fa-file-alt fa-fw" icon="far fa-file-alt fa-fw"
@click="setActiveNote(note.id)" @click="setActiveNote(note.id)"
:title="rootNote?.title" :title="rootNote?.title"
> >
{{ note.title }} {{ note.title }}
</SideBarMenuItem> </SideBarMenuItem>
</template> </template>
<SkeletonSidebarItem v-else :n="5" /> <SkeletonSidebarItem v-else :n="5" />
</template> </template>
</SideBarMenu> </SideBarMenu>
</div> </div>
</template> </template>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<div class="text-sm font-semibold uppercase text-secondary"> <div class="text-sm font-semibold uppercase text-secondary">
<slot name="header"></slot> <slot name="header"></slot>
</div>
<slot name="items"></slot>
</div> </div>
<slot name="items"></slot>
</div>
</template> </template>

View File

@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
icon?: string icon?: string
active?: boolean active?: boolean
}>() }>()
</script> </script>
<template> <template>
<a <a
class="mt-1 block w-full cursor-pointer truncate rounded hover:bg-gray-200 active:bg-primary active:text-primary-content max-sm:mt-2" class="mt-1 block w-full cursor-pointer truncate rounded hover:bg-gray-200 active:bg-primary active:text-primary-content max-sm:mt-2"
:class="props.active ? 'font-bold text-primary' : 'text-secondary'" :class="props.active ? 'font-bold text-primary' : 'text-secondary'"
> >
<i :class="props.icon" class="mr-2" v-if="props.icon"></i> <i :class="props.icon" class="mr-2" v-if="props.icon"></i>
<slot></slot> <slot></slot>
</a> </a>
</template> </template>

View File

@@ -1,30 +1,30 @@
<template> <template>
<div class="flex h-full w-full animate-pulse flex-col"> <div class="flex h-full w-full animate-pulse flex-col">
<div class="mb-2 flex items-center space-x-4 py-1"> <div class="mb-2 flex items-center space-x-4 py-1">
<div class="h-[2.25rem] w-[40px] rounded bg-secondary"></div> <div class="h-[2.25rem] w-[40px] rounded bg-secondary"></div>
<div class="h-[2.25rem] flex-grow rounded bg-secondary"></div> <div class="h-[2.25rem] flex-grow rounded bg-secondary"></div>
</div>
<div class="flex flex-grow flex-col gap-2">
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="mt-2 h-[2rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-4/6 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-7/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-6/12 rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
</div>
<hr class="my-3" />
<div class="flex gap-2">
<div class="h-[1.25rem] w-2/12 rounded bg-secondary"></div>
<div class="ml-auto h-[1.25rem] w-4/12 rounded bg-secondary"></div>
</div>
</div> </div>
<div class="flex flex-grow flex-col gap-2">
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="mt-2 h-[2rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-4/6 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-7/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-6/12 rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
</div>
<hr class="my-3" />
<div class="flex gap-2">
<div class="h-[1.25rem] w-2/12 rounded bg-secondary"></div>
<div class="ml-auto h-[1.25rem] w-4/12 rounded bg-secondary"></div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.bg-secondary { .bg-secondary {
@apply bg-secondary/25; @apply bg-secondary/25;
} }
</style> </style>

View File

@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
n?: number n?: number
}>(), }>(),
{ n: 1 } { n: 1 }
) )
</script> </script>
<template> <template>
<div class="flex w-full animate-pulse flex-col"> <div class="flex w-full animate-pulse flex-col">
<div class="mt-1 h-[1.35rem] w-full rounded bg-secondary" v-for="i in props.n" :key="i"></div> <div
</div> class="mt-1 h-[1.35rem] w-full rounded bg-secondary"
v-for="i in props.n"
:key="i"
></div>
</div>
</template> </template>
<style scoped> <style scoped>
.bg-secondary { .bg-secondary {
@apply bg-secondary/25; @apply bg-secondary/25;
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex h-full w-full animate-pulse space-x-2"> <div class="flex h-full w-full animate-pulse space-x-2">
<div class="h-full w-full rounded bg-white/10" /> <div class="h-full w-full rounded bg-white/10" />
<div class="h-full w-[44px] rounded bg-white/10" /> <div class="h-full w-[44px] rounded bg-white/10" />
</div> </div>
</template> </template>

View File

@@ -6,106 +6,109 @@ import { initialized } from '@/composables/useFirebase'
const loading = inject<boolean>('loading') const loading = inject<boolean>('loading')
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
sideBarCollapsed: boolean sideBarCollapsed: boolean
height?: number height?: number
}>(), }>(),
{ {
height: 52 height: 52
} }
) )
const emit = defineEmits<{ const emit = defineEmits<{
toggleSideBar: [] toggleSideBar: []
}>() }>()
const searchActive = ref<boolean>(false) const searchActive = ref<boolean>(false)
// const authUI: any = inject('firebaseAuthUI') // const authUI: any = inject('firebaseAuthUI')
// const authPending = ref<boolean>(authUI.isPendingRedirect()) // const authPending = ref<boolean>(authUI.isPendingRedirect())
// const authPending = ref(false)
const handleSignIn = async (close: () => Promise<boolean>) => { const handleSignIn = async (close: () => Promise<void>) => {
await close() await close()
// authPending.value = false // authPending.value = false
} }
</script> </script>
<template> <template>
<div class="z-[500] flex items-end bg-primary" :class="searchActive && 'search-active'"> <div class="z-[500] flex items-end bg-primary" :class="searchActive && 'search-active'">
<div <div
class="mx-auto flex w-full max-w-app items-center py-2.5 text-white" class="mx-auto flex w-full max-w-app items-center py-2.5 text-white"
:style="{ height: `${props.height}px` }" :style="{ height: `${props.height}px` }"
> >
<div <div
class="search-active-hide flex items-center pl-3" class="search-active-hide flex items-center pl-3"
:class="sideBarCollapsed ? 'w-fit' : 'max-sm:w-fit md:w-sidebar md:pr-3'" :class="sideBarCollapsed ? 'w-fit' : 'max-sm:w-fit md:w-sidebar md:pr-3'"
> >
<Hamburger <Hamburger
:side-bar-collapsed="props.sideBarCollapsed" :side-bar-collapsed="props.sideBarCollapsed"
@toggle-side-bar="emit('toggleSideBar')" @toggle-side-bar="emit('toggleSideBar')"
/> />
<Logo <Logo
class="ml-auto pl-5 text-2xl hover:drop-shadow" class="ml-auto pl-5 text-2xl hover:drop-shadow"
id="logo" id="logo"
@click="setActiveNote(rootNote?.id)" @click="setActiveNote(rootNote?.id)"
/> />
</div> </div>
<div class="flex h-full flex-grow flex-row items-center gap-2 pl-5 pr-3"> <div class="flex h-full flex-grow flex-row items-center gap-2 pl-5 pr-3">
<template v-if="!loading"> <template v-if="!loading">
<SearchBar @active="(active) => (searchActive = active)" /> <SearchBar @active="(active) => (searchActive = active)" />
<UIButton <UIButton
size="sm" size="sm"
variant="outline" variant="outline"
class="search-active-hide topbar-button text-white" class="search-active-hide topbar-button text-white"
@click="addNote('Untitled new note', '', true)" @click="addNote('Untitled new note', '', true)"
> >
<i class="fa-fw fa-solid fa-plus-circle scale-[115%]" /> <i class="fa-fw fa-solid fa-plus-circle scale-[115%]" />
</UIButton> </UIButton>
<UIModal v-if="(initialized && !user)"> <UIModal v-if="initialized && !user">
<template #activator="{ open }"> <template #activator="{ open }">
<UIButton <UIButton
size="sm" size="sm"
variant="outline" variant="outline"
class="search-active-hide topbar-button py-1 text-white" class="search-active-hide topbar-button py-1 text-white"
@click="open" @click="open"
> >
Sign in Sign in
</UIButton> </UIButton>
</template> </template>
<template #title>{{ 'Sign in' }}</template> <template #title>Sign in</template>
<template #default="{ close }"> <template #default="{ close }">
<Auth @signedIn="handleSignIn(close)" /> <Auth @signedIn="handleSignIn(close)" />
</template> </template>
<template #actions="{ close }"> <template #actions="{ close }">
<UIButton size="sm" @click="close">Close</UIButton> <UIButton size="sm" @click="close">Close</UIButton>
</template> </template>
</UIModal> </UIModal>
<Settings v-else-if="user" /> <Settings v-else-if="user" />
</template> </template>
<SkeletonTopBar v-else /> <SkeletonTopBar v-else />
</div> </div>
</div>
</div> </div>
</div>
</template> </template>
<style lang="scss"> <style lang="scss" scoped>
#logo { #logo {
@apply cursor-pointer transition-all duration-200 hover:text-primary; @apply cursor-pointer transition-all duration-200 active:text-primary;
} }
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
#logo:hover { #logo:hover {
text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white; text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white;
} @apply text-primary;
}
} }
#logo:active { #logo:active {
text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white; text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white;
} }
.topbar-button { .topbar-button {
@apply hover:border-white hover:bg-white hover:text-primary focus-visible:outline-white; &:active {
@apply border-white bg-white text-primary;
}
@apply hover:border-white hover:bg-white hover:text-primary focus-visible:outline-white;
} }
.search-active { .search-active {
.search-active-hide { .search-active-hide {
@apply max-sm:hidden; @apply max-sm:hidden;
} }
} }
</style> </style>

View File

@@ -1,61 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
sideBarCollapsed: boolean sideBarCollapsed: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
toggleSideBar: [] toggleSideBar: []
}>() }>()
</script> </script>
<template> <template>
<label <label
class="dui-btn-ghost dui-btn-sm dui-btn-circle dui-btn relative inline-grid cursor-pointer select-none place-content-center" class="dui-btn-ghost dui-btn dui-btn-sm dui-btn-circle relative inline-grid cursor-pointer select-none place-content-center"
>
<input type="checkbox" @click="emit('toggleSideBar')" :checked="!props.sideBarCollapsed" />
<svg
class="swap-off fill-current"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 512 512"
> >
<path d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" /> <input type="checkbox" @click="emit('toggleSideBar')" :checked="!props.sideBarCollapsed" />
</svg> <svg
<svg class="swap-off fill-current"
class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" width="24"
width="24" height="24"
height="24" viewBox="0 0 512 512"
viewBox="0 0 512 512" >
> <path d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" />
<polygon </svg>
points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49" <svg
/> class="swap-on fill-current"
</svg> xmlns="http://www.w3.org/2000/svg"
</label> width="24"
height="24"
viewBox="0 0 512 512"
>
<polygon
points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49"
/>
</svg>
</label>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
input { input {
appearance: none; appearance: none;
} }
label > * { label > * {
transition-duration: 0.2s; transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-property: transform, opacity; transition-property: transform, opacity;
grid-column-start: 1; grid-column-start: 1;
grid-row-start: 1; grid-row-start: 1;
} }
input:checked ~ .swap-off { input:checked ~ .swap-off {
opacity: 0; opacity: 0;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
input:checked ~ .swap-on { input:checked ~ .swap-on {
opacity: 1; opacity: 1;
transform: rotate(0deg); transform: rotate(0deg);
} }
.swap-on { .swap-on {
opacity: 0; opacity: 0;
transform: rotate(45deg); transform: rotate(45deg);
} }
</style> </style>

View File

@@ -1,3 +1,3 @@
<template> <template>
<i class="fa-brands fa-staylinked"></i> <i class="fa-brands fa-staylinked"></i>
</template> </template>

View File

@@ -1,19 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { OnClickOutside } from '@vueuse/components' import { OnClickOutside } from '@vueuse/components'
import { vibrate } from '@/composables/useHaptics'
</script> </script>
<template> <template>
<OnClickOutside> <OnClickOutside>
<UIDropdown class="search-active-hide"> <UIDropdown class="search-active-hide">
<template #activator> <template #activator>
<UIButton :dropdown="true" size="sm" variant="outline" class="topbar-button text-white"> <UIButton
<i class="fa-fw fa-solid fa-user-gear" /> dropdown
</UIButton> size="sm"
</template> variant="outline"
<template #items> class="topbar-button text-white"
<NotesSourceSwitcher /> @mousedown="vibrate"
<AccountSettings /> >
<SignOut /> <i class="fa-fw fa-solid fa-user-gear" />
</template> </UIButton>
</UIDropdown> </template>
</OnClickOutside> <template #items>
<NotesSourceSwitcher />
<AccountSettings />
<SignOut />
</template>
</UIDropdown>
</OnClickOutside>
</template> </template>
<style scoped lang="scss">
.topbar-button {
&:focus-within {
@apply border-white bg-white text-primary;
}
@apply hover:border-white hover:bg-white hover:text-primary focus-visible:outline-white;
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { sendEmailVerification } from 'firebase/auth'
import { user } from '@/composables/useFirebase' import { user } from '@/composables/useFirebase'
import { encryptionKey, enableEncryption, disableEncryption } from '@/composables/useEncryption' import { encryptionKey, enableEncryption, disableEncryption } from '@/composables/useEncryption'
import { notes } from '@/composables/useNotes' import { notes } from '@/composables/useNotes'
@@ -8,31 +9,29 @@ import FileSaver from 'file-saver'
const verificationEmailSent = ref(false) const verificationEmailSent = ref(false)
const sendVerificationMail = () => { const sendVerificationMail = () => {
if (!user.value) throw Error("User doesn't exist, can't send verification email") if (!user.value) throw Error("User doesn't exist, can't send verification email")
user.value.sendEmailVerification() sendEmailVerification(user.value)
verificationEmailSent.value = true verificationEmailSent.value = true
} }
console.log(user.value)
const exportNotes = async () => { const exportNotes = async () => {
const zip = new JSZip() const zip = new JSZip()
notes.value.forEach((note) => { notes.value.forEach((note) => {
zip.file(`${note.title}-${note.id}.md`, note.content) zip.file(`${note.title}-${note.id}.md`, note.content)
}) })
const blob = await zip.generateAsync({ type: 'blob' }) const blob = await zip.generateAsync({ type: 'blob' })
const currentDate = format(new Date(), 'yyyyMMdd') const currentDate = format(new Date(), 'yyyyMMdd')
FileSaver.saveAs(blob, `contexted-${user.value?.email}-${currentDate}.zip`) FileSaver.saveAs(blob, `contexted-${user.value?.email}-${currentDate}.zip`)
} }
const showDeleteAccountDialog = ref(false) const showDeleteAccountDialog = ref(false)
const deleteAccount = async () => { const deleteAccount = async () => {
await user.value?.delete() await user.value?.delete()
} }
const showEncryptionDialog = ref(false) const showEncryptionDialog = ref(false)
watch(showEncryptionDialog, () => { watch(showEncryptionDialog, () => {
passphrase.value = '' passphrase.value = ''
}) })
const passphrase = ref('') const passphrase = ref('')
const toggleEncryptionError = ref('') const toggleEncryptionError = ref('')
@@ -40,167 +39,205 @@ const toggleEncryptionError = ref('')
const encryptionEnabled = computed(() => Boolean(encryptionKey.value)) const encryptionEnabled = computed(() => Boolean(encryptionKey.value))
const toggleEncryption = async () => { const toggleEncryption = async () => {
const result = encryptionEnabled.value const result = encryptionEnabled.value
? await disableEncryption(passphrase.value) ? await disableEncryption(passphrase.value)
: await enableEncryption(passphrase.value) : await enableEncryption(passphrase.value)
if (typeof result === 'string') { if (typeof result === 'string') {
toggleEncryptionError.value = result toggleEncryptionError.value = result
} else { } else {
toggleEncryptionError.value = '' toggleEncryptionError.value = ''
showEncryptionDialog.value = false showEncryptionDialog.value = false
} }
} }
</script> </script>
<template> <template>
<UIModal size="lg"> <UIModal size="lg">
<template #activator="{ open }"> <template #activator="{ open }">
<UIDropdownItem @click="open"> <UIDropdownItem @click="open">
<i class="fa-fw fa-solid fa-sliders" /> <i class="fa-fw fa-solid fa-sliders" />
Account settings Account settings
</UIDropdownItem> </UIDropdownItem>
</template> </template>
<template #title> <template #title>
<i class="fa-fw fa-solid fa-sliders mr-2" /> <i class="fa-fw fa-solid fa-sliders mr-2" />
Account settings Account settings
</template> </template>
<template #default> <template #default>
<div class="space-y-2"> <div class="space-y-2">
<UICard> <UICard>
<template #title>Account</template> <template #title>Account</template>
<template #default> <template #default>
<div class="w-full flex-row sm:flex" v-if="user?.email"> <template v-if="user?.email">
<div class="font-bold sm:w-4/12">E-mail address</div> <div class="w-full flex-row sm:flex">
<div>{{ user?.email }}</div> <div class="font-bold sm:w-4/12">E-mail address</div>
<div>{{ user.email }}</div>
</div>
<div class="w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Account status</div>
<div class="col-auto">
<UIBadge :color="user.emailVerified ? 'success' : 'warning'">
{{ user.emailVerified ? 'Verified' : 'Not yet verified' }}
</UIBadge>
<UIButton
size="sm"
class="ml-2"
@click="sendVerificationMail"
v-if="!user.emailVerified"
:disabled="verificationEmailSent"
>
{{
verificationEmailSent
? 'Verification email sent'
: 'Request new verification email'
}}
</UIButton>
</div>
</div>
</template>
<div class="w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Account creation date</div>
<div>
{{
format(
Date.parse(user?.metadata?.creationTime || ''),
'dd/MM/yyyy'
)
}}
</div>
</div>
</template>
</UICard>
<UICard>
<template #title>Notes</template>
<template #default>
<div class="items-top w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Export notes</div>
<UIButton size="sm" @click="exportNotes">
<i class="fa-fw fa-solid fa-file-export mr-2"></i>
Export notes
</UIButton>
</div>
<div class="items-top w-full flex-row sm:flex sm:flex-grow">
<div class="flex-shrink-0 font-bold sm:w-4/12">Delete account</div>
<div>
<UIButton
size="sm"
color="error"
@click="showDeleteAccountDialog = true"
>
<i class="fa-fw fa-solid fa-trash mr-2"></i>
Delete account
</UIButton>
<UIAlert
color="warning"
density="compact"
class="mt-1 space-y-2 text-sm"
v-if="showDeleteAccountDialog"
>
<div>
Are you sure you want to delete your Contexted account? This
action cannot be undone!
</div>
<div class="flex flex-wrap gap-2">
<UIButton
size="sm"
variant="outline"
color="primary"
@click="deleteAccount"
>
Delete account
</UIButton>
<UIButton
size="sm"
variant="outline"
@click="showDeleteAccountDialog = false"
>
Cancel
</UIButton>
</div>
</UIAlert>
</div>
</div>
<div class="items-top w-full flex-row sm:flex">
<div class="flex-shrink-0 font-bold sm:w-4/12">
End-to-end encryption
</div>
<div class="w-full">
<template v-if="!encryptionEnabled">
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key"></i>
Enable end-to-end encryption
</UIButton>
</template>
<template v-else>
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key mr-2"></i>
Disable end-to-end encryption
</UIButton>
</template>
<UIAlert
color="info"
density="compact"
class="text-sm"
v-if="showEncryptionDialog"
>
<div class="w-full space-y-2">
<div>
Enter your passphrase to
{{ encryptionEnabled ? 'disable' : 'enable' }}
encryption
</div>
<UIInputText
size="sm"
type="password"
:color="toggleEncryptionError ? 'error' : 'regular'"
v-model="passphrase"
class="w-full !max-w-full"
/>
<UIAlert
density="compact"
color="error"
v-if="toggleEncryptionError"
>
<i class="fa-solid fa-triangle-exclamation"></i>
{{ toggleEncryptionError }}
</UIAlert>
<div class="flex flex-wrap gap-2">
<UIButton
:disabled="passphrase.length === 0"
size="sm"
variant="outline"
color="primary"
@click="toggleEncryption"
>
{{
encryptionEnabled ? 'Disable' : 'Enable'
}}
encryption
</UIButton>
<UIButton
size="sm"
variant="outline"
@click="showEncryptionDialog = false"
>
Cancel
</UIButton>
</div>
</div>
</UIAlert>
</div>
</div>
</template>
</UICard>
</div> </div>
<div class="w-full flex-row sm:flex" v-if="user?.email"> </template>
<div class="font-bold sm:w-4/12">Account status</div> </UIModal>
<div class="col-auto">
<UIBadge :color="user?.emailVerified ? 'success' : 'warning'">
{{ user?.emailVerified ? 'Verified' : 'Not yet verified' }}
</UIBadge>
<UIButton
size="sm"
class="ml-2"
@click="sendVerificationMail"
v-if="!user?.emailVerified"
:disabled="verificationEmailSent"
>
{{
verificationEmailSent
? 'Verification email sent'
: 'Request new verification email'
}}
</UIButton>
</div>
</div>
<div class="w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Account creation date</div>
<div>
{{ format(Date.parse(user?.metadata?.creationTime || ''), 'dd/MM/yyyy') }}
</div>
</div>
</template>
</UICard>
<UICard>
<template #title>Notes</template>
<template #default>
<div class="items-top w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Export notes</div>
<UIButton size="sm" @click="exportNotes">
<i class="fa-fw fa-solid fa-file-export"></i>
Export notes
</UIButton>
</div>
<div class="items-top w-full flex-row sm:flex sm:flex-grow">
<div class="flex-shrink-0 font-bold sm:w-4/12">Delete account</div>
<div>
<UIButton size="sm" color="error" @click="showDeleteAccountDialog = true">
<i class="fa-fw fa-solid fa-trash"></i>
Delete account
</UIButton>
<UIAlert
color="warning"
density="compact"
class="mt-1 space-y-2 text-sm"
v-if="showDeleteAccountDialog"
>
<div>
Are you sure you want to delete your Contexted account? This action cannot be
undone!
</div>
<div class="flex flex-wrap gap-2">
<UIButton size="sm" variant="outline" color="primary" @click="deleteAccount">
Delete account
</UIButton>
<UIButton size="sm" variant="outline" @click="showDeleteAccountDialog = false">
Cancel
</UIButton>
</div>
</UIAlert>
</div>
</div>
<div class="items-top w-full flex-row sm:flex">
<div class="flex-shrink-0 font-bold sm:w-4/12">End-to-end encryption</div>
<div class="w-full">
<template v-if="!encryptionEnabled">
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key"></i>
Enable end-to-end encryption
</UIButton>
</template>
<template v-else>
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key"></i>
Disable end-to-end encryption
</UIButton>
</template>
<UIAlert color="info" density="compact" class="text-sm" v-if="showEncryptionDialog">
<div class="w-full space-y-2">
<div>
Enter your passphrase to
{{ encryptionEnabled ? 'disable' : 'enable' }}
encryption
</div>
<UIInputText
size="sm"
type="password"
:color="toggleEncryptionError ? 'error' : 'regular'"
v-model="passphrase"
class="w-full !max-w-full"
/>
<UIAlert density="compact" color="error" v-if="toggleEncryptionError">
<i class="fa-solid fa-triangle-exclamation"></i>
{{ toggleEncryptionError }}
</UIAlert>
<div class="flex flex-wrap gap-2">
<UIButton
:disabled="passphrase.length === 0"
size="sm"
variant="outline"
color="primary"
@click="toggleEncryption"
>
{{ encryptionEnabled ? 'Disable' : 'Enable' }} encryption
</UIButton>
<UIButton size="sm" variant="outline" @click="showEncryptionDialog = false">
Cancel
</UIButton>
</div>
</div>
</UIAlert>
</div>
</div>
</template>
</UICard>
</div>
</template>
</UIModal>
</template> </template>

View File

@@ -3,24 +3,24 @@ import { activeNotesSource, availableNotesSources } from '@/composables/useNotes
import { preferredNotesSource } from '@/composables/useSettings' import { preferredNotesSource } from '@/composables/useSettings'
const sourceLabels: { [source: string]: string } = { const sourceLabels: { [source: string]: string } = {
local: 'Switch to local notes', local: 'Switch to local notes',
firebase: 'Switch to cloud notes' firebase: 'Switch to cloud notes'
} }
const blur = () => (document.activeElement as HTMLElement)?.blur() const blur = () => (document.activeElement as HTMLElement)?.blur()
const handleClick = (fn: (...args: any[]) => any) => { const handleClick = (fn: (...args: any[]) => any) => {
blur() blur()
fn() fn()
} }
</script> </script>
<template> <template>
<UIDropdownItem <UIDropdownItem
v-for="source in availableNotesSources.filter((source) => source !== activeNotesSource)" v-for="source in availableNotesSources.filter((source) => source !== activeNotesSource)"
:key="source" :key="source"
@click="handleClick(() => (preferredNotesSource = source))" @click="handleClick(() => (preferredNotesSource = source))"
> >
<i class="fa-fw fa-solid fa-database" /> <i class="fa-fw fa-solid fa-database" />
{{ sourceLabels[source] }} {{ sourceLabels[source] }}
</UIDropdownItem> </UIDropdownItem>
</template> </template>

View File

@@ -3,33 +3,33 @@ import { preferredNotesSource } from '@/composables/useSettings'
import { signOut as firebaseSignOut } from '@/composables/useFirebase' import { signOut as firebaseSignOut } from '@/composables/useFirebase'
import { clearEncryptionKeys } from '@/composables/useEncryption' import { clearEncryptionKeys } from '@/composables/useEncryption'
const signOut = async (close: () => Promise<boolean>) => { const signOut = async (close: () => Promise<void>) => {
await close() await close()
await firebaseSignOut() await firebaseSignOut()
preferredNotesSource.value = null preferredNotesSource.value = null
clearEncryptionKeys() clearEncryptionKeys()
} }
</script> </script>
<template> <template>
<UIModal> <UIModal>
<template #activator="{ open }"> <template #activator="{ open }">
<UIDropdownItem @click="open"> <UIDropdownItem @click="open">
<i class="fa-fw fa-solid fa-right-from-bracket" /> <i class="fa-fw fa-solid fa-right-from-bracket" />
Sign out Sign out
</UIDropdownItem> </UIDropdownItem>
</template> </template>
<template #title> <template #title>
<i class="fa-fw fa-solid fa-right-from-bracket mr-2" /> <i class="fa-fw fa-solid fa-right-from-bracket mr-2" />
Sign out Sign out
</template> </template>
<template #default> <template #default>
<p>Are you sure want to signout?</p> <p>Are you sure want to signout?</p>
<p>Your synchronized notes can't be accessed until you sign-in again.</p> <p>Your synchronized notes can't be accessed until you sign-in again.</p>
</template> </template>
<template #actions="{ close }"> <template #actions="{ close }">
<UIButton size="sm" @click="close">Cancel</UIButton> <UIButton size="sm" @click="close">Cancel</UIButton>
<UIButton size="sm" color="primary" @click="signOut(close)">Sign out</UIButton> <UIButton size="sm" color="primary" @click="signOut(close)">Sign out</UIButton>
</template> </template>
</UIModal> </UIModal>
</template> </template>

View File

@@ -3,105 +3,108 @@ import { getNoteReferences, setActiveNote, findNotes, deleteNote } from '@/compo
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const notesWithReferences = computed(() => { const notesWithReferences = computed(() => {
return findNotes(filter.value).map((note) => ({ return findNotes(filter.value).map((note) => ({
...note, ...note,
references: getNoteReferences(note) references: getNoteReferences(note)
})) }))
}) })
const selectedNotes = ref<{ [key: string]: Boolean }>({}) const selectedNotes = ref<{ [key: string]: Boolean }>({})
const countSelectedNotes = computed<number>( const countSelectedNotes = computed<number>(
() => Object.entries(selectedNotes.value).filter(([, selected]) => Boolean(selected)).length () => Object.entries(selectedNotes.value).filter(([, selected]) => Boolean(selected)).length
) )
const toggleRow = (note: Note) => { const toggleRow = (note: Note) => {
if (!note.isRoot) selectedNotes.value[note.id] = !selectedNotes.value[note.id] if (!note.isRoot) selectedNotes.value[note.id] = !selectedNotes.value[note.id]
} }
const filter = ref<string>('') const filter = ref<string>('')
const deleteSelectedNotes = (closeModal: () => void) => { const deleteSelectedNotes = (closeModal: () => void) => {
closeModal() closeModal()
const notesToDelete = Object.entries(selectedNotes.value) const notesToDelete = Object.entries(selectedNotes.value)
.filter(([, selected]) => Boolean(selected)) .filter(([, selected]) => Boolean(selected))
.map(([id]) => id) .map(([id]) => id)
notesToDelete.forEach((noteId) => deleteNote(noteId)) notesToDelete.forEach((noteId) => deleteNote(noteId))
selectedNotes.value = {} selectedNotes.value = {}
} }
</script> </script>
<template> <template>
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="flex items-center"> <div class="flex items-center">
<span class="whitespace-nowrap"> <span class="whitespace-nowrap">
{{ notesWithReferences.length }} {{ notesWithReferences.length === 1 ? 'note' : 'notes' }} {{ notesWithReferences.length }}
</span> {{ notesWithReferences.length === 1 ? 'note' : 'notes' }}
<template v-if="countSelectedNotes > 0"> </span>
<span class="mx-1">|</span> <template v-if="countSelectedNotes > 0">
<div class="whitespace-nowrap font-semibold">{{ countSelectedNotes }} selected</div> <span class="mx-1">|</span>
</template> <div class="whitespace-nowrap font-semibold">
</div> {{ countSelectedNotes }} selected
<UIModal v-if="countSelectedNotes > 0"> </div>
<template #activator="{ open }"> </template>
<UIButton size="sm" @click="open">Delete</UIButton> </div>
</template> <UIModal v-if="countSelectedNotes > 0">
<template #default>Are you sure you want to delete the selected notes?</template> <template #activator="{ open }">
<template #actions="{ close }"> <UIButton size="sm" @click="open">Delete</UIButton>
<UIButton size="sm" color="primary" @click="deleteSelectedNotes(close)"> </template>
Delete selected notes <template #default>Are you sure you want to delete the selected notes?</template>
</UIButton> <template #actions="{ close }">
<UIButton size="sm" @click="close">Close</UIButton> <UIButton size="sm" color="primary" @click="deleteSelectedNotes(close)">
</template> Delete selected notes
</UIModal> </UIButton>
<UIInputText <UIButton size="sm" @click="close">Close</UIButton>
size="sm" </template>
v-model="filter" </UIModal>
placeholder="Start typing to filter" <UIInputText
class="my-1 ml-auto mr-1 max-w-xs flex-grow" size="sm"
></UIInputText> v-model="filter"
placeholder="Start typing to filter"
class="my-1 ml-auto mr-1 max-w-xs flex-grow"
></UIInputText>
</div>
<div class="overflow-x-auto">
<UITable size="md" class="w-full">
<thead>
<tr class="bg-base-200 text-sm text-base-content">
<th class="w-[48px]"></th>
<th>Note title</th>
<th class="w-[75px]">Words</th>
<th class="w-[100px]">References</th>
<th class="w-[150px]">Modified</th>
</tr>
</thead>
<tbody>
<tr
v-for="note in notesWithReferences"
:key="note.id"
class="dui-hover hover:cursor-pointer"
@click="setActiveNote(note.id)"
>
<th @click.stop="toggleRow(note)" class="text-center">
<label>
<UIInputCheckbox
color="primary"
:checked="Boolean(selectedNotes[note.id])"
:disabled="note.isRoot"
></UIInputCheckbox>
</label>
</th>
<td>
<i class="fas fa-fw fa-home mr-1 text-secondary" v-if="note.isRoot" />
{{ note.title }}
</td>
<td>{{ note.wordCount }}</td>
<td>
<UIBadge v-if="note.references.length > 0">
<i data-v-41bbc26f="" class="fas fa-fw fa-sign-out-alt mr-1"></i>
{{ note.references.length }}
</UIBadge>
</td>
<td>{{ formatDate(note.modified) }}</td>
</tr>
</tbody>
</UITable>
</div>
</div> </div>
<div class="overflow-x-auto">
<UITable size="md" class="w-full">
<thead>
<tr class="bg-base-200 text-sm text-base-content">
<th class="w-[48px]"></th>
<th>Note title</th>
<th class="w-[75px]">Words</th>
<th class="w-[100px]">References</th>
<th class="w-[150px]">Modified</th>
</tr>
</thead>
<tbody>
<tr
v-for="note in notesWithReferences"
:key="note.id"
class="dui-hover hover:cursor-pointer"
@click="setActiveNote(note.id)"
>
<th @click.stop="toggleRow(note)" class="text-center">
<label>
<UIInputCheckbox
color="primary"
:checked="Boolean(selectedNotes[note.id])"
:disabled="note.isRoot"
></UIInputCheckbox>
</label>
</th>
<td>
<i class="fas fa-fw fa-home mr-1 text-secondary" v-if="note.isRoot" />
{{ note.title }}
</td>
<td>{{ note.wordCount }}</td>
<td>
<UIBadge v-if="note.references.length > 0">
<i data-v-41bbc26f="" class="fas fa-fw fa-sign-out-alt mr-1"></i>
{{ note.references.length }}
</UIBadge>
</td>
<td>{{ formatDate(note.modified) }}</td>
</tr>
</tbody>
</UITable>
</div>
</div>
</template> </template>

View File

@@ -1,294 +1,303 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
notesRelations, notesRelations,
getNoteById, getNoteById,
activeNote, activeNote,
setActiveNote, setActiveNote,
rootNote rootNote
} from '@/composables/useNotes' } from '@/composables/useNotes'
import cytoscape from 'cytoscape' import cytoscape from 'cytoscape'
import shortid from 'shortid' import shortid from 'shortid'
const renderMindmap = () => { const renderMindmap = () => {
const mindmapCanvas = mindmapElement.value const mindmapCanvas = mindmapElement.value
if (!mindmapCanvas) return if (!mindmapCanvas) return
const style = { const style = {
contextedBlue: '#1e4bc4', contextedBlue: '#1e4bc4',
nodeBackground: '#6c757d', nodeBackground: '#6c757d',
edge: '#ced4da' edge: '#ced4da'
} }
const boundingBox = { const boundingBox = {
x1: 0, x1: 0,
y1: 0, y1: 0,
w: mindmapCanvas.clientWidth, w: mindmapCanvas.clientWidth,
h: mindmapCanvas.clientHeight h: mindmapCanvas.clientHeight
} }
const elements = { const elements = {
nodes: nodes.value, nodes: nodes.value,
edges: [ edges: [
...links.value.map((link) => ({ data: { id: `${link.source}-${link.target}`, ...link } })) ...links.value.map((link) => ({
] data: { id: `${link.source}-${link.target}`, ...link }
} }))
const cy = cytoscape({ ]
container: mindmapCanvas, }
elements, const cy = cytoscape({
autoungrabify: true, container: mindmapCanvas,
autounselectify: true, elements,
layout: { autoungrabify: true,
name: 'cose', autounselectify: true,
layout: {
name: 'cose',
// Called on `layoutready` // Called on `layoutready`
ready: function () {}, ready: function () {},
// Called on `layoutstop` // Called on `layoutstop`
stop: function () {}, stop: function () {},
// Whether to animate while running the layout // Whether to animate while running the layout
// true : Animate continuously as the layout is running // true : Animate continuously as the layout is running
// false : Just show the end result // false : Just show the end result
// 'end' : Animate with the end result, from the initial positions to the end positions // 'end' : Animate with the end result, from the initial positions to the end positions
animate: false, animate: false,
// Easing of the animation for animate:'end' // Easing of the animation for animate:'end'
animationEasing: undefined, animationEasing: undefined,
// The duration of the animation for animate:'end' // The duration of the animation for animate:'end'
animationDuration: undefined, animationDuration: undefined,
// A function that determines whether the node should be animated // A function that determines whether the node should be animated
// All nodes animated by default on animate enabled // All nodes animated by default on animate enabled
// Non-animated nodes are positioned immediately when the layout starts // Non-animated nodes are positioned immediately when the layout starts
// animateFilter: function (node, i) { // animateFilter: function (node, i) {
animateFilter: function () { animateFilter: function () {
return true return true
}, },
// The layout animates only after this many milliseconds for animate:true // The layout animates only after this many milliseconds for animate:true
// (prevents flashing on fast runs) // (prevents flashing on fast runs)
animationThreshold: 250, animationThreshold: 250,
// Number of iterations between consecutive screen positions update // Number of iterations between consecutive screen positions update
refresh: 20, refresh: 20,
// Whether to fit the network view after when done // Whether to fit the network view after when done
fit: true, fit: true,
// Padding on fit // Padding on fit
padding: 30, padding: 30,
// Constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h } // Constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h }
// boundingBox: undefined, // boundingBox: undefined,
boundingBox, boundingBox,
// Excludes the label when calculating node bounding boxes for the layout algorithm // Excludes the label when calculating node bounding boxes for the layout algorithm
nodeDimensionsIncludeLabels: false, nodeDimensionsIncludeLabels: false,
// Randomize the initial positions of the nodes (true) or use existing positions (false) // Randomize the initial positions of the nodes (true) or use existing positions (false)
randomize: false, randomize: false,
// Extra spacing between components in non-compound graphs // Extra spacing between components in non-compound graphs
componentSpacing: 40, componentSpacing: 40,
// Node repulsion (non overlapping) multiplier // Node repulsion (non overlapping) multiplier
// nodeRepulsion: function (node) { // nodeRepulsion: function (node) {
nodeRepulsion: function () { nodeRepulsion: function () {
return 2048 return 2048
}, },
// Node repulsion (overlapping) multiplier // Node repulsion (overlapping) multiplier
nodeOverlap: 4, nodeOverlap: 4,
// Ideal edge (non nested) length // Ideal edge (non nested) length
// idealEdgeLength: function (edge) { // idealEdgeLength: function (edge) {
idealEdgeLength: function () { idealEdgeLength: function () {
return 32 return 32
}, },
// Divisor to compute edge forces // Divisor to compute edge forces
// edgeElasticity: function (edge) { // edgeElasticity: function (edge) {
edgeElasticity: function () { edgeElasticity: function () {
return 32 return 32
}, },
// Nesting factor (multiplier) to compute ideal edge length for nested edges // Nesting factor (multiplier) to compute ideal edge length for nested edges
nestingFactor: 1.2, nestingFactor: 1.2,
// Gravity force (constant) // Gravity force (constant)
gravity: 1, gravity: 1,
// Maximum number of iterations to perform // Maximum number of iterations to perform
numIter: 1000, numIter: 1000,
// Initial temperature (maximum node displacement) // Initial temperature (maximum node displacement)
initialTemp: 1000, initialTemp: 1000,
// Cooling factor (how the temperature is reduced between consecutive iterations // Cooling factor (how the temperature is reduced between consecutive iterations
coolingFactor: 0.99, coolingFactor: 0.99,
// Lower temperature threshold (below this point the layout will end) // Lower temperature threshold (below this point the layout will end)
minTemp: 1.0 minTemp: 1.0
}, },
// userZoomingEnabled: false, // userZoomingEnabled: false,
userPanningEnabled: false, userPanningEnabled: false,
pixelRatio: window.devicePixelRatio ? window.devicePixelRatio * 1.5 : 'auto', pixelRatio: window.devicePixelRatio ? window.devicePixelRatio * 1.5 : 'auto',
style: [ style: [
// the stylesheet for the graph // the stylesheet for the graph
{ {
selector: 'node', selector: 'node',
style: { style: {
'background-color': style.nodeBackground, 'background-color': style.nodeBackground,
label: 'data(title)', label: 'data(title)',
'font-family': 'Source Sans Pro, sans-serif', 'font-family': 'Source Sans Pro, sans-serif',
'font-weight': 400, 'font-weight': 400,
'font-size': '1.5em', 'font-size': '1.5em',
'text-events': 'yes' 'text-events': 'yes'
} }
}, },
{ {
selector: '.current', selector: '.current',
style: { style: {
'background-color': style.contextedBlue 'background-color': style.contextedBlue
} }
}, },
{ {
selector: '.mouseover', selector: '.mouseover',
style: { style: {
'background-color': style.contextedBlue, 'background-color': style.contextedBlue,
color: style.contextedBlue color: style.contextedBlue
} }
}, },
{ {
selector: 'edge', selector: 'edge',
style: { style: {
width: 3, width: 3,
'line-color': style.edge, 'line-color': style.edge,
'target-arrow-color': style.edge, 'target-arrow-color': style.edge,
'target-arrow-shape': 'triangle', 'target-arrow-shape': 'triangle',
'curve-style': 'bezier' 'curve-style': 'bezier'
} }
} }
] ]
}) })
cy.nodes().forEach((node) => { cy.nodes().forEach((node) => {
if (node.data('id') === activeNote.value?.id) node.addClass('current') if (node.data('id') === activeNote.value?.id) node.addClass('current')
}) })
cy.nodes().on('tap', (event) => { cy.nodes().on('tap', (event) => {
setActiveNote(event.target.data('id')) setActiveNote(event.target.data('id'))
}) })
cy.on('mouseover', 'node', (event) => { cy.on('mouseover', 'node', (event) => {
event.target.addClass('mouseover') event.target.addClass('mouseover')
mindmapCanvas.classList.add('mouseover') mindmapCanvas.classList.add('mouseover')
}) })
cy.on('mouseout', 'node', (event) => { cy.on('mouseout', 'node', (event) => {
event.target.removeClass('mouseover') event.target.removeClass('mouseover')
mindmapCanvas.classList.remove('mouseover') mindmapCanvas.classList.remove('mouseover')
}) })
} }
const mindmapElement = ref<HTMLInputElement | null>(null) const mindmapElement = ref<HTMLInputElement | null>(null)
interface Mindmap { interface Mindmap {
id: string id: string
notes: string[] notes: string[]
isRoot: boolean isRoot: boolean
} }
const selectedMindmap = ref<Mindmap>() const selectedMindmap = ref<Mindmap>()
const mindmaps = computed<Mindmap[]>(() => { const mindmaps = computed<Mindmap[]>(() => {
const mindmaps = Object.entries(notesRelations.value).reduce((mindmaps, [noteId, relations]) => { const mindmaps = Object.entries(notesRelations.value).reduce(
const atomicMindmap = [noteId, ...relations.to, ...relations.from] (mindmaps, [noteId, relations]) => {
const indices = mindmaps const atomicMindmap = [noteId, ...relations.to, ...relations.from]
.filter( const indices = mindmaps
(mindmap) => [...mindmap].filter((noteId) => atomicMindmap.includes(noteId)).length > 0 .filter(
) (mindmap) =>
.map((mindmap) => mindmaps.indexOf(mindmap)) [...mindmap].filter((noteId) => atomicMindmap.includes(noteId)).length > 0
if (indices.length > 0) { )
const index = indices[0] .map((mindmap) => mindmaps.indexOf(mindmap))
const currentMindmap = indices.reduce( if (indices.length > 0) {
(mindmap, index) => mindmap.concat(mindmaps[index]), const index = indices[0]
[] as string[] const currentMindmap = indices.reduce(
) (mindmap, index) => mindmap.concat(mindmaps[index]),
indices.forEach((index, i) => { [] as string[]
if (i !== 0) mindmaps.splice(index, 1) )
}) indices.forEach((index, i) => {
mindmaps[index] = [...currentMindmap, ...atomicMindmap].filter( if (i !== 0) mindmaps.splice(index, 1)
(item, index, arr) => arr.indexOf(item) === index })
) mindmaps[index] = [...currentMindmap, ...atomicMindmap].filter(
} else { (item, index, arr) => arr.indexOf(item) === index
mindmaps.push(atomicMindmap) )
} } else {
mindmaps.push(atomicMindmap)
}
return mindmaps
},
[] as string[][]
)
return mindmaps return mindmaps
}, [] as string[][]) .filter((mindmap) => mindmap.length > 1)
return mindmaps .sort((a, b) => b.length - a.length)
.filter((mindmap) => mindmap.length > 1) .sort((a, b) => {
.sort((a, b) => b.length - a.length) return (
.sort((a, b) => { Number(b.includes(rootNote.value?.id || '')) -
return ( Number(a.includes(rootNote.value?.id || ''))
Number(b.includes(rootNote.value?.id || '')) - Number(a.includes(rootNote.value?.id || '')) )
) })
}) .slice(0, 5)
.slice(0, 5) .map((mindmap): Mindmap => {
.map((mindmap): Mindmap => { const isRoot = mindmap.includes(rootNote.value?.id || '')
const isRoot = mindmap.includes(rootNote.value?.id || '') return { id: shortid.generate(), notes: mindmap, isRoot }
return { id: shortid.generate(), notes: mindmap, isRoot } })
})
}) })
watch( watch(
mindmaps, mindmaps,
() => { () => {
if (!selectedMindmap.value) selectedMindmap.value = mindmaps.value[0] if (!selectedMindmap.value) selectedMindmap.value = mindmaps.value[0]
}, },
{ immediate: true } { immediate: true }
) )
watch(selectedMindmap, () => setTimeout(() => renderMindmap(), 0), { immediate: true }) watch(selectedMindmap, () => setTimeout(() => renderMindmap(), 0), { immediate: true })
const nodes = computed(() => { const nodes = computed(() => {
return ( return (
Object.entries(notesRelations.value) Object.entries(notesRelations.value)
// .filter(([, relations]) => relations.to.length > 0) // .filter(([, relations]) => relations.to.length > 0)
.filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId)) .filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId))
.map(([noteId]) => { .map(([noteId]) => {
return { return {
data: { data: {
id: noteId, id: noteId,
title: getNoteById(noteId)?.title title: getNoteById(noteId)?.title
} }
} }
}) })
) )
}) })
const links = computed(() => { const links = computed(() => {
return Object.entries(notesRelations.value) return Object.entries(notesRelations.value)
.filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId)) .filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId))
.filter(([, relations]) => relations.to.length > 0) .filter(([, relations]) => relations.to.length > 0)
.map(([noteId, relations]) => { .map(([noteId, relations]) => {
return relations.to.map((to) => ({ return relations.to.map((to) => ({
source: noteId, source: noteId,
target: to target: to
})) }))
}) })
.reduce((arr, elem) => arr.concat(elem), []) .reduce((arr, elem) => arr.concat(elem), [])
}) })
</script> </script>
<template> <template>
<div class="flex h-full flex-grow flex-col"> <div class="flex h-full flex-grow flex-col">
<UITabs> <div class="flex">
<UITab <UITabs>
v-for="mindmap in mindmaps" <UITab
:key="mindmap.id" v-for="mindmap in mindmaps"
:active="mindmap.id === selectedMindmap?.id" :key="mindmap.id"
@click="selectedMindmap = mindmap" :active="mindmap.id === selectedMindmap?.id"
> @click="selectedMindmap = mindmap"
<i class="fas fa-fw fa-home root mr-1" v-if="mindmap.isRoot" /> >
{{ mindmap.notes.length }} notes <i class="fas fa-fw fa-home root mr-1" v-if="mindmap.isRoot" />
</UITab> {{ mindmap.notes.length }} notes
</UITabs> </UITab>
<div id="mindmap" ref="mindmapElement" class="h-full"></div> </UITabs>
</div> </div>
<div id="mindmap" ref="mindmapElement" class="h-full"></div>
</div>
</template> </template>
<style scoped> <style scoped>
.mouseover { .mouseover {
@apply hover:cursor-pointer; @apply hover:cursor-pointer;
} }
</style> </style>

View File

@@ -1,69 +1,74 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { import {
activeNote, activeNote,
deleteNote, deleteNote,
rootNote, rootNote,
setRootNote, setRootNote,
setActiveNote, setActiveNote,
getNoteReferences getNoteReferences
} from '@/composables/useNotes' } from '@/composables/useNotes'
const props = defineProps<{ const props = defineProps<{
note: Note note: Note
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
update: [note: Note] update: [note: Note]
}>() }>()
const noteTitle = ref<string>(props.note.title) const noteTitle = ref<string>(props.note.title)
watch(noteTitle, () => { watch(noteTitle, () => {
const updatedNote: Note = { ...props.note, title: noteTitle.value } const updatedNote: Note = { ...props.note, title: noteTitle.value }
emit('update', updatedNote) emit('update', updatedNote)
}) })
const updateNoteContent = (content: string) => { const updateNoteContent = (content: string) => {
const updatedNote: Note = { ...props.note, content } const updatedNote: Note = { ...props.note, content }
emit('update', updatedNote) emit('update', updatedNote)
} }
const references = computed<Note[]>(() => getNoteReferences(props.note)) const references = computed<Note[]>(() => getNoteReferences(props.note))
const handleAction = async (action: string, closeModal: () => Promise<Boolean>) => { const handleAction = async (action: 'delete' | 'setRoot', closeModal?: () => Promise<void>) => {
if (action === 'delete') { switch (action) {
if (closeModal) await closeModal() case 'delete':
setActiveNote(rootNote.value?.id) if (closeModal) await closeModal()
deleteNote(props.note.id) setActiveNote(rootNote.value?.id)
} deleteNote(props.note.id)
if (action === 'setRoot') { break
setRootNote(props.note.id) case 'setRoot':
if (closeModal) closeModal() setRootNote(props.note.id)
} if (closeModal) closeModal()
}
} }
</script> </script>
<template> <template>
<div class="flex flex-grow flex-col"> <div class="flex flex-grow flex-col">
<NoteToolbar :note="props.note" @execute="handleAction"> <NoteToolbar :note="props.note" @execute="handleAction">
<template #title> <template #title>
<i <i
class="fas fa-fw fa-home mr-2 text-base text-secondary opacity-40" class="fas fa-fw fa-home mr-2 text-base text-secondary opacity-40"
v-if="props.note.isRoot" v-if="props.note.isRoot"
></i> ></i>
<input type="text" class="w-full bg-transparent pb-1 outline-none" v-model="noteTitle" /> <input
</template> type="text"
</NoteToolbar> class="w-full bg-transparent pb-1 outline-none"
<NoteEditor v-model="noteTitle"
class="flex-grow" />
:note="activeNote" </template>
@update="updateNoteContent" </NoteToolbar>
v-if="activeNote" <NoteEditor
></NoteEditor> class="flex-grow"
<NoteReferences :references="references" /> :note="activeNote"
<hr class="my-3" /> @update="updateNoteContent"
<div class="flex text-sm text-secondary"> v-if="activeNote"
<span>{{ note.wordCount }} {{ note.wordCount === 1 ? 'word' : 'words' }}</span> ></NoteEditor>
<span class="ml-auto">Last modified {{ formatDate(note.modified) }}</span> <NoteReferences :references="references" />
<hr class="my-3" />
<div class="flex text-sm text-secondary">
<span>{{ note.wordCount }} {{ note.wordCount === 1 ? 'word' : 'words' }}</span>
<span class="ml-auto">Last modified {{ formatDate(note.modified) }}</span>
</div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,31 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
color?: 'info' | 'success' | 'warning' | 'error' color?: 'info' | 'success' | 'warning' | 'error'
density?: 'regular' | 'compact' density?: 'regular' | 'compact'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
color: 'info', color: 'info',
density: 'regular' density: 'regular'
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const colorVariants = { const colorVariants = {
info: 'bg-info', info: 'bg-info',
success: 'bg-success', success: 'bg-success',
warning: 'bg-warning', warning: 'bg-warning',
error: 'bg-error' error: 'bg-error'
} }
const densityVariants = { const densityVariants = {
regular: 'py-4 px-4', regular: 'py-4 px-4',
compact: 'py-2 px-4' compact: 'py-2 px-4'
} }
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
const densityClass = densityVariants[props.density] const densityClass = densityVariants[props.density]
return [colorClass, densityClass] return [colorClass, densityClass]
}) })
</script> </script>
<template> <template>
<div class="rounded-xl shadow-lg" :class="styleClass"> <div class="rounded-xl shadow-lg" :class="styleClass">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@@ -1,41 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
variant?: 'regular' | 'outline' | 'ghost' variant?: 'regular' | 'outline' | 'ghost'
color?: 'regular' | 'info' | 'success' | 'warning' | 'error' color?: 'regular' | 'info' | 'success' | 'warning' | 'error'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md', size: 'md',
variant: 'regular', variant: 'regular',
color: 'regular' color: 'regular'
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-badge-xs', xs: 'dui-badge-xs',
sm: 'dui-badge-sm', sm: 'dui-badge-sm',
md: 'dui-badge-md', md: 'dui-badge-md',
lg: 'dui-badge-lg' lg: 'dui-badge-lg'
} }
const variantVariants = { const variantVariants = {
regular: '', regular: '',
outline: 'dui-badge-outline', outline: 'dui-badge-outline',
ghost: 'dui-badge-ghost' ghost: 'dui-badge-ghost'
} }
const colorVariants = { const colorVariants = {
regular: '', regular: '',
info: 'dui-badge-info', info: 'dui-badge-info',
success: 'dui-badge-success', success: 'dui-badge-success',
warning: 'dui-badge-warning', warning: 'dui-badge-warning',
error: 'dui-badge-error' error: 'dui-badge-error'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
const variantClass = variantVariants[props.variant] const variantClass = variantVariants[props.variant]
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
return [sizeClass, variantClass, colorClass] return [sizeClass, variantClass, colorClass]
}) })
</script> </script>
<template> <template>
<span class="dui-badge" :class="styleClass"><slot></slot></span> <span class="dui-badge" :class="styleClass"><slot></slot></span>
</template> </template>

View File

@@ -1,57 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
variant?: 'regular' | 'outline' variant?: 'regular' | 'outline'
color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
dropdown?: boolean dropdown?: boolean
join?: boolean join?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md', size: 'md',
variant: 'regular', variant: 'regular',
color: 'regular', color: 'regular',
dropdown: false, dropdown: false,
join: false join: false
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-btn-xs', xs: 'dui-btn-xs',
sm: 'dui-btn-sm', sm: 'dui-btn-sm',
md: 'dui-btn-md', md: 'dui-btn-md',
lg: 'dui-btn-lg' lg: 'dui-btn-lg'
} }
const colorVariants = { const colorVariants = {
regular: '', regular: '',
primary: 'dui-btn-primary', primary: 'dui-btn-primary',
secondary: 'dui-btn-secondary', secondary: 'dui-btn-secondary',
info: 'dui-btn-info', info: 'dui-btn-info',
success: 'dui-btn-success', success: 'dui-btn-success',
warning: 'dui-btn-warning', warning: 'dui-btn-warning',
error: 'dui-btn-error' error: 'dui-btn-error'
} }
const variantVariants = { const variantVariants = {
regular: '', regular: '',
outline: 'dui-btn-outline' outline: 'dui-btn-outline'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
const variantClass = variantVariants[props.variant] const variantClass = variantVariants[props.variant]
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
const joinClass = props.join ? 'dui-join-item' : '' const joinClass = props.join ? 'dui-join-item' : ''
return [sizeClass, variantClass, colorClass, joinClass] return [sizeClass, variantClass, colorClass, joinClass]
}) })
</script> </script>
<template> <template>
<label <label
class="dui-btn h-auto px-3 py-2 duration-0" class="dui-btn h-auto px-3 py-2 duration-0"
:class="styleClass" :class="styleClass"
v-if="props.dropdown" v-if="props.dropdown"
tabindex="0" tabindex="0"
> >
<slot></slot> <slot></slot>
</label> </label>
<button type="button" class="dui-btn h-auto px-3 py-2 duration-0" :class="styleClass" v-else> <button
<slot></slot> type="button"
</button> class="dui-btn inline-block h-auto max-w-full truncate px-3 py-2 duration-0"
:class="styleClass"
v-else
>
<slot></slot>
</button>
</template> </template>

View File

@@ -1,3 +1,3 @@
<template> <template>
<div class="dui-join"><slot></slot></div> <div class="dui-join"><slot></slot></div>
</template> </template>

View File

@@ -1,9 +1,9 @@
<template> <template>
<div class="dui-card bg-base-100 shadow-sm"> <div class="dui-card bg-base-100 shadow-sm">
<div class="dui-card-body"> <div class="dui-card-body">
<h3 class="dui-card-title" v-if="$slots.title"><slot name="title" /></h3> <h3 class="dui-card-title" v-if="$slots.title"><slot name="title" /></h3>
<slot></slot> <slot></slot>
<div class="dui-card-actions justify-end" v-if="$slots.actions"></div> <div class="dui-card-actions justify-end" v-if="$slots.actions"></div>
</div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="dui-dropdown-end dui-dropdown"> <div class="dui-dropdown-end dui-dropdown">
<slot name="activator" tabindex="0"></slot> <slot name="activator" tabindex="0"></slot>
<ul <ul
tabindex="0" tabindex="0"
class="dui-menu-compact dui-dropdown-content dui-menu rounded-box mt-1 w-52 bg-base-100 p-2 text-base-content shadow" class="dui-menu-compact dui-dropdown-content dui-menu mt-1 w-52 rounded-box bg-base-100 p-2 text-base-content shadow"
> >
<slot name="items"></slot> <slot name="items"></slot>
</ul> </ul>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<li class="text-sm"> <li class="text-sm">
<a class="rounded-lg"><slot></slot></a> <a class="rounded-lg"><slot></slot></a>
</li> </li>
</template> </template>

View File

@@ -1,35 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
modelValue?: any modelValue?: any
color?: 'regular' | 'primary' color?: 'regular' | 'primary'
checked?: boolean checked?: boolean
disabled?: boolean disabled?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
color: 'regular' color: 'regular'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: any] 'update:modelValue': [value: any]
}>() }>()
const styleClass = computed(() => { const styleClass = computed(() => {
const colorVariants = { const colorVariants = {
regular: '', regular: '',
primary: 'dui-checkbox-primary' primary: 'dui-checkbox-primary'
} }
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
return [colorClass] return [colorClass]
}) })
</script> </script>
<template> <template>
<input <input
type="checkbox" type="checkbox"
class="dui-checkbox dui-checkbox-sm border-secondary" class="dui-checkbox dui-checkbox-sm border-secondary"
:class="styleClass" :class="styleClass"
:checked="props.modelValue" :checked="props.modelValue || props.checked"
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)" @change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
:disabled="props.disabled" :disabled="props.disabled"
/> />
</template> </template>

View File

@@ -1,46 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
modelValue?: any modelValue?: any
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md', size: 'md',
color: 'regular' color: 'regular'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: any] 'update:modelValue': [value: any]
}>() }>()
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-input-xs', xs: 'dui-input-xs',
sm: 'dui-input-sm', sm: 'dui-input-sm',
md: 'dui-input-md', md: 'dui-input-md',
lg: 'dui-input-lg' lg: 'dui-input-lg'
} }
const colorVariants = { const colorVariants = {
regular: '', regular: '',
primary: 'dui-input-primary', primary: 'dui-input-primary',
secondary: 'dui-input-secondary', secondary: 'dui-input-secondary',
info: 'dui-input-info', info: 'dui-input-info',
success: 'dui-input-success', success: 'dui-input-success',
warning: 'dui-input-warning', warning: 'dui-input-warning',
error: 'dui-input-error' error: 'dui-input-error'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
return [sizeClass, colorClass] return [sizeClass, colorClass]
}) })
</script> </script>
<template> <template>
<input <input
type="text" type="text"
:value="props.modelValue" :value="props.modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement)?.value)" @input="emit('update:modelValue', ($event.target as HTMLInputElement)?.value)"
class="dui-input-bordered dui-input my-1 ml-auto mr-1 max-w-xs flex-grow" class="dui-input-bordered dui-input my-1 ml-auto mr-1 max-w-xs flex-grow"
:class="styleClass" :class="styleClass"
/> />
</template> </template>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
compact?: boolean compact?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const styleClass = computed(() => { const styleClass = computed(() => {
const compactClass = props.compact && 'dui-menu-compact' const compactClass = props.compact && 'dui-menu-compact'
return [compactClass] return [compactClass]
}) })
</script> </script>
<template> <template>
<ul tabindex="0" class="dui-menu rounded-md bg-base-100" :class="styleClass"> <ul tabindex="0" class="dui-menu rounded-md bg-base-100" :class="styleClass">
<slot></slot> <slot></slot>
</ul> </ul>
</template> </template>

View File

@@ -1,28 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
title?: boolean title?: boolean
disabled?: boolean disabled?: boolean
active?: boolean active?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const styleClass = computed(() => { const styleClass = computed(() => {
const titleClass = props.title const titleClass = props.title
? 'dui-menu-item dui-menu-title !opacity-100 space-x-2 !text-xl font-bold !text-secondary' ? 'dui-menu-item dui-menu-title !opacity-100 space-x-2 !text-xl font-bold !text-secondary'
: '' : ''
return [titleClass] return [titleClass]
}) })
</script> </script>
<template> <template>
<li :class="styleClass"> <li :class="styleClass">
<span class="flex items-center" v-if="props.title"><slot></slot></span> <span class="flex items-center" v-if="props.title">
<a <slot></slot>
class="flex w-full rounded-md" </span>
:class="{ 'dui-disabled': props.disabled, 'dui-active': props.active }" <a
v-else class="flex w-full rounded-md"
> :class="{ 'dui-disabled': props.disabled, 'dui-active': props.active }"
<slot></slot> v-else
</a> >
</li> <slot></slot>
</a>
</li>
</template> </template>
<style scoped>
.dui-active {
@apply bg-primary;
}
</style>

View File

@@ -1,42 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
import { Haptics, ImpactStyle } from '@capacitor/haptics' import { vibrate } from '@/composables/useHaptics'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
open?: boolean open?: boolean
persistent?: boolean persistent?: boolean
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
icon?: string icon?: string
}>(), }>(),
{ {
open: false, open: false,
persistent: false, persistent: false,
size: 'md' size: 'md'
} }
) )
const show = ref<boolean>(false) const show = ref<boolean>(false)
watch( watch(
() => props.open, () => props.open,
() => { () => {
if (show.value) { if (show.value) vibrate()
Haptics.impact({ style: ImpactStyle.Light })
} show.value = props.open
show.value = props.open },
}, { immediate: true }
{ immediate: true }
) )
const modal = ref<HTMLElement | null>(null) const modal = ref<HTMLElement | null>(null)
const modalBox = ref(null) const modalBox = ref(null)
const open = () => (show.value = true) const open = () => (show.value = true)
const close = (): Promise<boolean> => { const close = (): Promise<void> => {
return new Promise((resolve) => { return new Promise((resolve) => {
modal.value?.addEventListener('transitionend', () => resolve(true)) modal.value?.addEventListener('transitionend', () => resolve())
show.value = false show.value = false
}) })
} }
const slotProps = { open, close } const slotProps = { open, close }
@@ -44,48 +43,48 @@ const slotProps = { open, close }
if (!props.persistent) onClickOutside(modalBox, () => close()) if (!props.persistent) onClickOutside(modalBox, () => close())
const onEnter = (el: Element, done: () => void): void => { const onEnter = (el: Element, done: () => void): void => {
setTimeout(() => { setTimeout(() => {
el.classList.add('dui-modal-open') el.classList.add('dui-modal-open')
done() done()
}) })
} }
const onLeave = (el: Element, done: () => void): void => { const onLeave = (el: Element, done: () => void): void => {
el.classList.remove('dui-modal-open') el.classList.remove('dui-modal-open')
el.addEventListener('transitionend', () => done()) el.addEventListener('transitionend', () => done())
} }
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
sm: 'max-w-xs', sm: 'max-w-xs',
md: 'max-w-md', md: 'max-w-md',
lg: 'max-w-2xl' lg: 'max-w-2xl'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
return [sizeClass] return [sizeClass]
}) })
defineExpose({ open, close }) defineExpose({ open, close })
</script> </script>
<template> <template>
<slot name="activator" v-bind="slotProps"></slot> <slot name="activator" v-bind="slotProps"></slot>
<Teleport to="body"> <Teleport to="body">
<Transition @enter="onEnter" @leave="onLeave" appear> <Transition @enter="onEnter" @leave="onLeave" appear>
<div class="dui-modal bg-neutral-800 bg-opacity-60" v-if="show" ref="modal"> <div class="dui-modal bg-neutral-800 bg-opacity-60" v-if="show" ref="modal">
<div class="dui-modal-box" :class="styleClass" ref="modalBox"> <div class="dui-modal-box" :class="styleClass" ref="modalBox">
<h3 class="mb-4 flex items-center text-xl font-bold" v-if="$slots.title"> <h3 class="mb-4 flex items-center text-xl font-bold" v-if="$slots.title">
<slot name="title" /> <slot name="title" />
</h3> </h3>
<div> <div>
<slot v-bind="slotProps" /> <slot v-bind="slotProps" />
</div> </div>
<div class="dui-modal-action mt-4"> <div class="dui-modal-action mt-4">
<slot name="actions" v-bind="slotProps"> <slot name="actions" v-bind="slotProps">
<UIButton size="sm" @click="close">Close</UIButton> <UIButton size="sm" @click="close">Close</UIButton>
</slot> </slot>
</div> </div>
</div> </div>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
</template> </template>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
active?: boolean active?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const styleClass = computed(() => { const styleClass = computed(() => {
const activeClass = props.active && 'dui-tab-active !border-primary text-primary' const activeClass = props.active && 'dui-tab-active font-bold !border-primary text-primary'
return [activeClass] return [activeClass]
}) })
</script> </script>
<template> <template>
<a class="dui-tab-bordered dui-tab-lifted dui-tab dui-tab-md" :class="styleClass"> <a class="dui-tab-bordered dui-tab hover:font-bold hover:text-primary" :class="styleClass">
<slot></slot> <slot></slot>
</a> </a>
</template> </template>

View File

@@ -1,23 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md' size: 'md'
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-table-xs', xs: 'dui-table-xs',
sm: 'dui-table-sm', sm: 'dui-table-sm',
md: 'dui-table-md', md: 'dui-table-md',
lg: 'dui-table-lg' lg: 'dui-table-lg'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
return [sizeClass] return [sizeClass]
}) })
</script> </script>
<template> <template>
<table class="dui-table" :class="styleClass"><slot></slot></table> <table class="dui-table" :class="styleClass"><slot></slot></table>
</template> </template>

View File

@@ -1,3 +1,3 @@
<template> <template>
<div class="dui-tabs"><slot></slot></div> <div class="dui-tabs dui-tabs-boxed dui-tabs-md"><slot></slot></div>
</template> </template>

View File

@@ -5,150 +5,156 @@ import { preferredNotesSource } from '@/composables/useSettings'
import { activeNotesSource, syncNotesToFirebase, baseNotes } from '@/composables/useNotes' import { activeNotesSource, syncNotesToFirebase, baseNotes } from '@/composables/useNotes'
function getClientKeysFromLocalStorage(): { [uid: string]: string } { function getClientKeysFromLocalStorage(): { [uid: string]: string } {
try { try {
return JSON.parse(localStorage.getItem('clientKeys') || '{}') return JSON.parse(localStorage.getItem('clientKeys') || '{}')
} catch (e) { } catch (e) {
return {} return {}
} }
} }
export const clientKey = ref<ClientKey>() export const clientKey = ref<ClientKey>()
export const getClientKey = () => { export const getClientKey = () => {
if (!user.value) return if (!user.value) return
const clientKeys = getClientKeysFromLocalStorage() const clientKeys = getClientKeysFromLocalStorage()
clientKey.value = clientKeys[user.value?.uid] clientKey.value = clientKeys[user.value?.uid]
} }
export const setClientKey = (passphrase: string) => { export const setClientKey = (passphrase: string) => {
const calculatedClientKey = calculateClientKey(passphrase) const calculatedClientKey = calculateClientKey(passphrase)
const verified = verifyClientKey(calculatedClientKey) const verified = verifyClientKey(calculatedClientKey)
if (!user.value || !verified) return false if (!user.value || !verified) return false
const clientKeys = getClientKeysFromLocalStorage() const clientKeys = getClientKeysFromLocalStorage()
clientKeys[user.value.uid] = calculatedClientKey clientKeys[user.value.uid] = calculatedClientKey
localStorage.setItem('clientKeys', JSON.stringify(clientKeys)) localStorage.setItem('clientKeys', JSON.stringify(clientKeys))
clientKey.value = calculatedClientKey clientKey.value = calculatedClientKey
getEncryptionKey() getEncryptionKey()
return true return true
} }
export const verifyClientKey = (clientKey: ClientKey) => { export const verifyClientKey = (clientKey: ClientKey) => {
try { try {
if (!encryptedEncryptionKey.value) throw new Error('Encryption key is null') if (!encryptedEncryptionKey.value) throw new Error('Encryption key is null')
if (!clientKey) throw new Error('Client key is null') if (!clientKey) throw new Error('Client key is null')
decrypt(encryptedEncryptionKey.value, clientKey) decrypt(encryptedEncryptionKey.value, clientKey)
return true return true
} catch (e) { } catch (e) {
console.log(e) console.log(e)
return false return false
} }
} }
const removeClientKey = () => { const removeClientKey = () => {
if (!user.value) return if (!user.value) return
const clientKeys = structuredClone(getClientKeysFromLocalStorage()) const clientKeys = structuredClone(getClientKeysFromLocalStorage())
delete clientKeys[user.value?.uid] delete clientKeys[user.value?.uid]
localStorage.setItem('clientKeys', JSON.stringify(clientKeys)) localStorage.setItem('clientKeys', JSON.stringify(clientKeys))
} }
const encryptedEncryptionKey = ref<EncryptedEncryptionKey | null>() const encryptedEncryptionKey = ref<EncryptedEncryptionKey | null>()
async function getEncryptedEncryptionKey(): Promise<EncryptedEncryptionKey | void> { async function getEncryptedEncryptionKey(): Promise<EncryptedEncryptionKey | void> {
if (!user.value || !db.value) return if (!user.value || !db.value) return
const data = (await getDoc(doc(db.value, 'encryptionKeys', user.value.uid))).data() const data = (await getDoc(doc(db.value, 'encryptionKeys', user.value.uid))).data()
return data?.key return data?.key
} }
async function setEncryptedEncryptionKey(encryptedEncryptionKey: EncryptedEncryptionKey | null) { async function setEncryptedEncryptionKey(encryptedEncryptionKey: EncryptedEncryptionKey | null) {
if (!user.value || !db.value) return if (!user.value || !db.value) return
const docRef = doc(db.value, 'encryptionKeys', user.value.uid) const docRef = doc(db.value, 'encryptionKeys', user.value.uid)
await setDoc(docRef, { key: encryptedEncryptionKey }) await setDoc(docRef, { key: encryptedEncryptionKey })
} }
export const encryptionKey = ref<EncryptionKey | null>() export const encryptionKey = ref<EncryptionKey | null>()
export async function getEncryptionKey() { export async function getEncryptionKey() {
encryptedEncryptionKey.value = (await getEncryptedEncryptionKey()) || undefined encryptedEncryptionKey.value = (await getEncryptedEncryptionKey()) || undefined
if (encryptedEncryptionKey.value && clientKey.value) { if (encryptedEncryptionKey.value && clientKey.value) {
encryptionKey.value = decrypt(encryptedEncryptionKey.value, clientKey.value) encryptionKey.value = decrypt(encryptedEncryptionKey.value, clientKey.value)
} else if (!encryptedEncryptionKey.value) { } else if (!encryptedEncryptionKey.value) {
encryptionKey.value = null encryptionKey.value = null
} else { } else {
encryptionKey.value = undefined encryptionKey.value = undefined
} }
} }
export const passphraseRequired = computed(() => { export const passphraseRequired = computed(() => {
return Boolean(encryptedEncryptionKey.value && !clientKey.value) return Boolean(encryptedEncryptionKey.value && !clientKey.value)
}) })
const decryptNote = (note: BaseNote, key: EncryptionKey) => { const decryptNote = (note: BaseNote, key: EncryptionKey) => {
try { try {
return { return {
...note, ...note,
title: decrypt(note.title, key), title: decrypt(note.title, key),
content: decrypt(note.content, key) content: decrypt(note.content, key)
}
} catch (error: any) {
console.error(error)
return note
} }
} catch (error: any) {
console.error(error)
return note
}
} }
export const decryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => { export const decryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => {
const decryptedNotes = Object.fromEntries( const decryptedNotes = Object.fromEntries(
Object.entries(notes).map(([noteId, note]) => [noteId, { ...decryptNote(note, encryptionKey) }]) Object.entries(notes).map(([noteId, note]) => [
) noteId,
return decryptedNotes { ...decryptNote(note, encryptionKey) }
])
)
return decryptedNotes
} }
const encryptNote = (note: BaseNote, key: EncryptionKey) => { const encryptNote = (note: BaseNote, key: EncryptionKey) => {
return { return {
...note, ...note,
title: encrypt(note.title, key), title: encrypt(note.title, key),
content: encrypt(note.content, key) content: encrypt(note.content, key)
} }
} }
export const encryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => { export const encryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => {
const encryptedNotes = Object.fromEntries( const encryptedNotes = Object.fromEntries(
Object.entries(notes).map(([noteId, note]) => [noteId, { ...encryptNote(note, encryptionKey) }]) Object.entries(notes).map(([noteId, note]) => [
) noteId,
return encryptedNotes { ...encryptNote(note, encryptionKey) }
])
)
return encryptedNotes
} }
export const verifyPassphrase = (passphrase: string) => { export const verifyPassphrase = (passphrase: string) => {
const calculatedClientKey = calculateClientKey(passphrase) const calculatedClientKey = calculateClientKey(passphrase)
return calculatedClientKey === clientKey.value return calculatedClientKey === clientKey.value
} }
export const disableEncryption = async (passphrase: string) => { export const disableEncryption = async (passphrase: string) => {
if (!encryptionKey.value) return "Encryption key doesn't exist." if (!encryptionKey.value) return "Encryption key doesn't exist."
if (!verifyPassphrase(passphrase)) return 'Passphrase is incorrect.' if (!verifyPassphrase(passphrase)) return 'Passphrase is incorrect.'
preferredNotesSource.value = 'firebase' preferredNotesSource.value = 'firebase'
if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.') if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.')
await setEncryptedEncryptionKey(null) await setEncryptedEncryptionKey(null)
encryptedEncryptionKey.value = null encryptedEncryptionKey.value = null
encryptionKey.value = undefined encryptionKey.value = undefined
removeClientKey() removeClientKey()
await syncNotesToFirebase(baseNotes.value) await syncNotesToFirebase(baseNotes.value)
getEncryptionKey() getEncryptionKey()
} }
export const enableEncryption = async (passphrase: string) => { export const enableEncryption = async (passphrase: string) => {
preferredNotesSource.value = 'firebase' preferredNotesSource.value = 'firebase'
if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.') if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.')
const candidateEncryptionKey = generateEncryptionKey() const candidateEncryptionKey = generateEncryptionKey()
const candidateClientKey = calculateClientKey(passphrase) const candidateClientKey = calculateClientKey(passphrase)
const candidateEncryptedEncryptionKey = encrypt(candidateEncryptionKey, candidateClientKey) const candidateEncryptedEncryptionKey = encrypt(candidateEncryptionKey, candidateClientKey)
await setEncryptedEncryptionKey(candidateEncryptedEncryptionKey) await setEncryptedEncryptionKey(candidateEncryptedEncryptionKey)
encryptedEncryptionKey.value = candidateEncryptedEncryptionKey encryptedEncryptionKey.value = candidateEncryptedEncryptionKey
encryptionKey.value = candidateEncryptionKey encryptionKey.value = candidateEncryptionKey
setClientKey(passphrase) setClientKey(passphrase)
await syncNotesToFirebase(baseNotes.value) await syncNotesToFirebase(baseNotes.value)
} }
export const clearEncryptionKeys = () => { export const clearEncryptionKeys = () => {
encryptionKey.value = undefined encryptionKey.value = undefined
encryptedEncryptionKey.value = undefined encryptedEncryptionKey.value = undefined
} }

View File

@@ -1,13 +1,24 @@
// import { initializeApp } from 'firebase/app' // import { initializeApp } from 'firebase/app'
import firebase from 'firebase/compat/app' import { Capacitor } from '@capacitor/core'
import type { User } from '@firebase/auth-types' // import firebase from 'firebase/compat/app'
// import type { User } from '@firebase/auth-types'
import { initializeApp } from 'firebase/app'
import { import {
initializeFirestore, initializeFirestore,
persistentLocalCache, persistentLocalCache,
persistentMultipleTabManager persistentMultipleTabManager
} from 'firebase/firestore' } from 'firebase/firestore'
import {
getAuth,
indexedDBLocalPersistence,
initializeAuth,
onAuthStateChanged,
signOut as firebaseSignOut
} from 'firebase/auth'
import { type FirebaseApp } from 'firebase/app'
import { type User } from '@firebase/auth'
import type { Firestore } from 'firebase/firestore' import type { Firestore } from 'firebase/firestore'
import { getAuth, getRedirectResult } from 'firebase/auth'
// import { getAnalytics } from "firebase/analytics"; // import { getAnalytics } from "firebase/analytics";
// TODO: Add SDKs for Firebase products that you want to use // TODO: Add SDKs for Firebase products that you want to use
@@ -16,62 +27,45 @@ import { getAuth, getRedirectResult } from 'firebase/auth'
// Your web app's Firebase configuration // Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional // For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = { const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY, apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL, databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID, appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
} }
export const user = ref<User | null>() export const user = ref<User | null>()
export const initialized = computed<boolean>(() => user.value !== undefined) export const initialized = computed<boolean>(() => user.value !== undefined)
export const signOut = () => firebase.auth().signOut() export const signOut = async () => firebaseSignOut(getAuth())
export const db = ref<Firestore>() export const db = ref<Firestore>()
const getFirebaseAuth = async (app: FirebaseApp) => {
if (Capacitor.isNativePlatform()) {
return initializeAuth(app, {
persistence: indexedDBLocalPersistence
})
} else {
return getAuth()
}
}
// Initialize Firebase // Initialize Firebase
export const initializeFirebase = async () => { export const initializeFirebase = async () => {
const app = firebase.initializeApp(firebaseConfig) const app = initializeApp(firebaseConfig)
const auth = getAuth() const auth = await getFirebaseAuth(app)
try { onAuthStateChanged(auth, (firebaseUser) => {
const authRedirectResult = await getRedirectResult(auth) console.log('auth state changed', firebaseUser)
console.log(authRedirectResult) user.value = firebaseUser
// user.value = result
// This gives you a Google Access Token. You can use it to access Google APIs.
// const credential = GoogleAuthProvider.credentialFromResult(result)
// const token = credential.accessToken
// The signed-in user info.
// const user = result.user
// IdP data available using getAdditionalUserInfo(result)
// ...
} catch (error: any) {
console.error(error)
// const errorCode = error.code
// // const errorMessage = error.message
// const email = error.customData.email
// Handle Errors here.
// const errorCode = error.code
// const errorMessage = error.message
// The email of the user's account used.
// const email = error.customData.email
// The AuthCredential type that was used.
// const credential = GoogleAuthProvider.credentialFromError(error)
// ...
}
firebase.auth().onAuthStateChanged((firebaseUser) => {
user.value = firebaseUser
})
db.value = markRaw(
initializeFirestore(app, {
localCache: persistentLocalCache({ tabManager: persistentMultipleTabManager() })
}) })
) db.value = markRaw(
initializeFirestore(app, {
localCache: persistentLocalCache({ tabManager: persistentMultipleTabManager() })
})
)
} }

View File

@@ -0,0 +1,3 @@
import { Haptics, ImpactStyle } from '@capacitor/haptics'
export const vibrate = () => Haptics.impact({ style: ImpactStyle.Light })

View File

@@ -8,17 +8,17 @@ import { defaultNotes } from '@/utils/defaultNotes'
import { mdToHtml } from '@/utils/markdown' import { mdToHtml } from '@/utils/markdown'
import { getAllMatches } from '@/utils/helpers' import { getAllMatches } from '@/utils/helpers'
import { preferredNotesSource } from '@/composables/useSettings' import { preferredNotesSource } from '@/composables/useSettings'
import { Haptics, ImpactStyle } from '@capacitor/haptics' import { vibrate } from '@/composables/useHaptics'
export const notesSources = computed(() => ({ export const notesSources = computed(() => ({
local: true, local: true,
firebase: initialized.value && user.value firebase: initialized.value && user.value
})) }))
export const availableNotesSources = computed<notesSourceValues[]>(() => export const availableNotesSources = computed<notesSourceValues[]>(() =>
Object.entries(notesSources.value) Object.entries(notesSources.value)
.filter(([, enabled]) => enabled) .filter(([, enabled]) => enabled)
.map(([source]) => source as notesSourceValues) .map(([source]) => source as notesSourceValues)
) )
export type notesSourceValues = keyof typeof notesSources.value export type notesSourceValues = keyof typeof notesSources.value
@@ -26,249 +26,255 @@ export type notesSourceValues = keyof typeof notesSources.value
export const activeNotesSource = ref<notesSourceValues | null>(null) export const activeNotesSource = ref<notesSourceValues | null>(null)
watchEffect(() => { watchEffect(() => {
const getSource = (): notesSourceValues | null => { const getSource = (): notesSourceValues | null => {
if (!initialized.value) return null if (!initialized.value) return null
if ( if (
preferredNotesSource.value && preferredNotesSource.value &&
availableNotesSources.value.includes(preferredNotesSource.value) availableNotesSources.value.includes(preferredNotesSource.value)
) { ) {
return preferredNotesSource.value return preferredNotesSource.value
} else { } else {
return user.value ? 'firebase' : 'local' return user.value ? 'firebase' : 'local'
}
} }
} activeNotesSource.value = getSource()
activeNotesSource.value = getSource()
}) })
export const baseNotes = ref<BaseNotes>({}) export const baseNotes = ref<BaseNotes>({})
const syncNotesLocal = (notes: BaseNotes) => { const syncNotesLocal = (notes: BaseNotes) => {
localStorage.setItem('notes', JSON.stringify(notes)) localStorage.setItem('notes', JSON.stringify(notes))
} }
// export const syncNotesToFirebase = async (newNotes: BaseNotes, oldNotes?: BaseNotes) => { // export const syncNotesToFirebase = async (newNotes: BaseNotes, oldNotes?: BaseNotes) => {
export const syncNotesToFirebase = async (baseNotes: BaseNotes) => { export const syncNotesToFirebase = async (baseNotes: BaseNotes) => {
if (!db.value) throw Error("Database undefined, can't sync to Firebase") if (!db.value) throw Error("Database undefined, can't sync to Firebase")
if (!user.value) throw Error("User undefined, can't sync to Firebase") if (!user.value) throw Error("User undefined, can't sync to Firebase")
const notes = encryptionKey.value ? encryptNotes(baseNotes, encryptionKey.value) : baseNotes const notes = encryptionKey.value ? encryptNotes(baseNotes, encryptionKey.value) : baseNotes
try { try {
const docRef = doc(db.value, 'pages', user.value.uid) const docRef = doc(db.value, 'pages', user.value.uid)
// if (oldNotes) { // if (oldNotes) {
// const notesToDelete = Object.keys(oldNotes).filter((x) => !Object.keys(newNotes).includes(x)) // const notesToDelete = Object.keys(oldNotes).filter((x) => !Object.keys(newNotes).includes(x))
// await Promise.all( // await Promise.all(
// notesToDelete.map((noteId: string) => { // notesToDelete.map((noteId: string) => {
// return updateDoc(docRef, { [noteId]: deleteField() }) // return updateDoc(docRef, { [noteId]: deleteField() })
// }) // })
// ) // )
// } // }
await setDoc(docRef, notes) await setDoc(docRef, notes)
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error)
} }
} }
watch( watch(
baseNotes, baseNotes,
async () => { async () => {
if (!activeNotesSource.value || Object.keys(baseNotes.value).length === 0) return if (!activeNotesSource.value || Object.keys(baseNotes.value).length === 0) return
if (activeNotesSource.value === 'local') { if (activeNotesSource.value === 'local') {
syncNotesLocal(baseNotes.value) syncNotesLocal(baseNotes.value)
} else if (activeNotesSource.value === 'firebase') { } else if (activeNotesSource.value === 'firebase') {
syncNotesToFirebase(baseNotes.value) syncNotesToFirebase(baseNotes.value)
} }
}, },
{ deep: true } { deep: true }
) )
export const notes = computed<Note[]>(() => { export const notes = computed<Note[]>(() => {
return Object.entries(baseNotes.value) return Object.entries(baseNotes.value)
.map(([, note]) => ({ .map(([, note]) => ({
...note, ...note,
wordCount: note.content.split(' ').filter((word) => word.length > 0).length wordCount: note.content.split(' ').filter((word) => word.length > 0).length
})) }))
.sort((a, b) => b.modified - a.modified) as Note[] .sort((a, b) => b.modified - a.modified) as Note[]
}) })
watch(notes, () => { watch(notes, () => {
if (notes.value.length > 0 && !activeNote.value && activeViewMode.value.name === 'Note') if (notes.value.length > 0 && !activeNote.value && activeViewMode.value.name === 'Note')
setActiveNote(rootNote.value?.id, false) setActiveNote(rootNote.value?.id, false)
}) })
const activeNoteId = ref<string>() const activeNoteId = ref<string>()
export const activeNote = computed(() => notes.value.find((note) => note.id === activeNoteId.value)) export const activeNote = computed(() => notes.value.find((note) => note.id === activeNoteId.value))
watch(activeNote, () => { watch(activeNote, () => {
if (activeNote.value) { if (activeNote.value) {
useTitle(`${activeNote.value.title} | Contexted`) useTitle(`${activeNote.value.title} | Contexted`)
} }
}) })
export const setActiveNote = (noteId: string | undefined, haptic: boolean = true) => { export const setActiveNote = (noteId: string | undefined, haptic: boolean = true) => {
if (noteId) { if (noteId) {
activeNoteId.value = noteId activeNoteId.value = noteId
activeViewMode.value = viewModes.find((mode) => mode.name === 'Note') || viewModes[0] activeViewMode.value = viewModes.find((mode) => mode.name === 'Note') || viewModes[0]
if (haptic) Haptics.impact({ style: ImpactStyle.Light }) if (haptic) vibrate()
} }
} }
export const rootNote = computed<Note | undefined>(() => { export const rootNote = computed<Note | undefined>(() => {
const rootNote = notes.value.find((note: Note) => note.isRoot) const rootNote = notes.value.find((note: Note) => note.isRoot)
return rootNote return rootNote
}) })
export const setRootNote = (noteId: string) => { export const setRootNote = (noteId: string) => {
if (rootNote.value) { if (rootNote.value) {
const updatedRootNote = { ...baseNotes.value[rootNote.value.id], isRoot: false } const updatedRootNote = { ...baseNotes.value[rootNote.value.id], isRoot: false }
updateNote(updatedRootNote.id, updatedRootNote) updateNote(updatedRootNote.id, updatedRootNote)
} }
const note = { ...baseNotes.value[noteId], isRoot: true } const note = { ...baseNotes.value[noteId], isRoot: true }
updateNote(noteId, note) updateNote(noteId, note)
setActiveNote(noteId, false) setActiveNote(noteId, false)
} }
export const insertDefaultNotes = (defaultNotes: BaseNote[]) => { export const insertDefaultNotes = (defaultNotes: BaseNote[]) => {
defaultNotes.forEach((defaultNote) => { defaultNotes.forEach((defaultNote) => {
baseNotes.value[defaultNote.id] = defaultNote baseNotes.value[defaultNote.id] = defaultNote
}) })
} }
export const getNoteById = (noteId: string) => { export const getNoteById = (noteId: string) => {
return notes.value.find((note) => note.id === noteId) return notes.value.find((note) => note.id === noteId)
} }
export const getNoteByTitle = (title: string) => { export const getNoteByTitle = (title: string) => {
return notes.value.find((note) => note.title === title) return notes.value.find((note) => note.title === title)
} }
export const findNotesByByTitle = (title: string) => { export const findNotesByByTitle = (title: string) => {
const titleLowerCase = title.toLowerCase() const titleLowerCase = title.toLowerCase()
return notes.value.filter((note) => note.title.toLowerCase().includes(titleLowerCase)) return notes.value.filter((note) => note.title.toLowerCase().includes(titleLowerCase))
} }
export const findNotes = (query: string): Note[] => { export const findNotes = (query: string): Note[] => {
const removeMdFromText = (mdText: string): string => { const removeMdFromText = (mdText: string): string => {
const div = document.createElement('div') const div = document.createElement('div')
div.innerHTML = mdToHtml(mdText) div.innerHTML = mdToHtml(mdText)
const textWithoutMd = div.textContent || div.innerText || '' const textWithoutMd = div.textContent || div.innerText || ''
return textWithoutMd return textWithoutMd
} }
return notes.value.filter((note) => { return notes.value.filter((note) => {
const matchTitle = note.title.toLowerCase().includes(query.toLowerCase()) const matchTitle = note.title.toLowerCase().includes(query.toLowerCase())
const matchContent = removeMdFromText(note.content).toLowerCase().includes(query.toLowerCase()) const matchContent = removeMdFromText(note.content)
return matchTitle || matchContent .toLowerCase()
}) .includes(query.toLowerCase())
return matchTitle || matchContent
})
} }
export const updateNote = (noteId: string, note: BaseNote) => { export const updateNote = (noteId: string, note: BaseNote) => {
const updatedNote: BaseNote = { const updatedNote: BaseNote = {
...note, ...note,
modified: new Date().getTime() modified: new Date().getTime()
} }
baseNotes.value[noteId] = updatedNote baseNotes.value[noteId] = updatedNote
} }
export const addNote = (title: string, content: string, goToNote: boolean = false) => { export const addNote = (title: string, content: string, goToNote: boolean = false) => {
const id = shortid.generate() const id = shortid.generate()
const newNote: BaseNote = { const newNote: BaseNote = {
id, id,
title, title,
content, content,
isRoot: false, isRoot: false,
created: new Date().getTime(), created: new Date().getTime(),
modified: new Date().getTime() modified: new Date().getTime()
} }
baseNotes.value[id] = newNote baseNotes.value[id] = newNote
if (goToNote) setActiveNote(id) if (goToNote) setActiveNote(id)
return newNote return newNote
} }
export const deleteNote = (noteId: string) => { export const deleteNote = (noteId: string) => {
const baseNotesClone: BaseNotes = structuredClone(toRaw(baseNotes.value)) const baseNotesClone: BaseNotes = structuredClone(toRaw(baseNotes.value))
delete baseNotesClone[noteId] delete baseNotesClone[noteId]
baseNotes.value = baseNotesClone baseNotes.value = baseNotesClone
} }
const getNoteLinksByNoteId = (noteId: string): string[] => { const getNoteLinksByNoteId = (noteId: string): string[] => {
const note = baseNotes.value[noteId] const note = baseNotes.value[noteId]
const regex = /\[\[(.*?)\]\]/g const regex = /\[\[(.*?)\]\]/g
const links = getAllMatches(regex, note.content || '') const links = getAllMatches(regex, note.content || '')
.map((to) => notes.value.find((note) => note.title === to[1])?.id || '') .map((to) => notes.value.find((note) => note.title === to[1])?.id || '')
.filter((noteId) => Object.keys(baseNotes.value).includes(noteId)) .filter((noteId) => Object.keys(baseNotes.value).includes(noteId))
return [...links] return [...links]
} }
export const notesRelations = computed(() => { export const notesRelations = computed(() => {
const noteIds = Object.keys(baseNotes.value) const noteIds = Object.keys(baseNotes.value)
const relations = noteIds const relations = noteIds
.filter((id) => id !== undefined) .filter((id) => id !== undefined)
.map((id) => { .map((id) => {
const to = getNoteLinksByNoteId(id) const to = getNoteLinksByNoteId(id)
return { id, to } return { id, to }
}) })
.map((noteRelations, _, notesRelations): NoteRelations => { .map((noteRelations, _, notesRelations): NoteRelations => {
const from = [...notesRelations] const from = [...notesRelations]
.map((noteRelation) => .map((noteRelation) =>
noteRelation.to.filter((toId) => toId === noteRelations.id).map(() => noteRelation.id) noteRelation.to
) .filter((toId) => toId === noteRelations.id)
.reduce((arr, elem) => arr.concat(elem), []) .map(() => noteRelation.id)
.filter((value, index, self) => self.indexOf(value) === index) )
return { .reduce((arr, elem) => arr.concat(elem), [])
id: noteRelations.id, .filter((value, index, self) => self.indexOf(value) === index)
to: noteRelations.to, return {
from id: noteRelations.id,
} to: noteRelations.to,
}) from
.reduce((notes, { id, to, from }) => { }
notes[id] = { to, from } })
return notes .reduce((notes, { id, to, from }) => {
}, {} as NotesRelations) notes[id] = { to, from }
return relations return notes
}, {} as NotesRelations)
return relations
}) })
export function getNoteReferences(note: Note) { export function getNoteReferences(note: Note) {
const relations = notesRelations.value[note.id] const relations = notesRelations.value[note.id]
return relations return relations
? (relations.from || []) ? (relations.from || [])
.map((noteId) => { .map((noteId) => {
return notes.value.find((note) => note.id === noteId) return notes.value.find((note) => note.id === noteId)
}) })
.filter((note): note is Note => note !== undefined) .filter((note): note is Note => note !== undefined)
: [] : []
} }
const parseBaseNotes = (notes: BaseNotes): BaseNotes => { const parseBaseNotes = (notes: BaseNotes): BaseNotes => {
return Object.fromEntries( return Object.fromEntries(
Object.entries(notes).map(([noteId, note]) => { Object.entries(notes).map(([noteId, note]) => {
return [ return [
noteId, noteId,
{ {
id: noteId, id: noteId,
title: note.title, title: note.title,
content: note.content, content: note.content,
isRoot: Boolean(note.isRoot), isRoot: Boolean(note.isRoot),
created: note.created || note.modified || new Date().getTime(), created: note.created || note.modified || new Date().getTime(),
modified: note.modified || note.created || new Date().getTime() modified: note.modified || note.created || new Date().getTime()
} }
] ]
}) })
) )
} }
export const getNotes = async () => { export const getNotes = async () => {
baseNotes.value = {} baseNotes.value = {}
let notes: BaseNotes = {} let notes: BaseNotes = {}
if (activeNotesSource.value === 'local') { if (activeNotesSource.value === 'local') {
try { try {
notes = JSON.parse(localStorage.getItem('notes') || '{}') notes = JSON.parse(localStorage.getItem('notes') || '{}')
} catch (error) { } catch (error) {
console.log(error) console.log(error)
}
} else if (activeNotesSource.value === 'firebase') {
if (encryptionKey.value === undefined || !user.value || !db.value) return
const firebaseNotes = (
await getDoc(doc(db.value, 'pages', user.value.uid))
).data() as BaseNotes
notes = encryptionKey.value
? decryptNotes(firebaseNotes, encryptionKey.value)
: firebaseNotes || {}
console.log('get notes from firebase', notes)
} }
} else if (activeNotesSource.value === 'firebase') { baseNotes.value = parseBaseNotes(notes)
if (encryptionKey.value === undefined || !user.value || !db.value) return if (!rootNote.value) insertDefaultNotes(defaultNotes)
const firebaseNotes = (await getDoc(doc(db.value, 'pages', user.value.uid))).data() as BaseNotes setActiveNote(rootNote.value?.id, false)
notes = encryptionKey.value
? decryptNotes(firebaseNotes, encryptionKey.value)
: firebaseNotes || {}
console.log('get notes from firebase', notes)
}
baseNotes.value = parseBaseNotes(notes)
if (!rootNote.value) insertDefaultNotes(defaultNotes)
setActiveNote(rootNote.value?.id, false)
} }

View File

@@ -1,24 +1,24 @@
import type { notesSourceValues } from '@/composables/useNotes' import type { notesSourceValues } from '@/composables/useNotes'
interface Settings { interface Settings {
preferredNotesSource: notesSourceValues | null preferredNotesSource: notesSourceValues | null
} }
export const preferredNotesSource = ref<notesSourceValues | null>(null) export const preferredNotesSource = ref<notesSourceValues | null>(null)
const updateSettings = () => { const updateSettings = () => {
const settings: Settings = { const settings: Settings = {
preferredNotesSource: preferredNotesSource.value preferredNotesSource: preferredNotesSource.value
} }
localStorage.setItem('settings', JSON.stringify(settings)) localStorage.setItem('settings', JSON.stringify(settings))
} }
export const initializeSettings = () => { export const initializeSettings = () => {
watch([preferredNotesSource], () => updateSettings()) watch([preferredNotesSource], () => updateSettings())
try { try {
const settings: Settings = JSON.parse(localStorage.getItem('settings') || '{}') const settings: Settings = JSON.parse(localStorage.getItem('settings') || '{}')
preferredNotesSource.value = settings.preferredNotesSource preferredNotesSource.value = settings.preferredNotesSource
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} }

View File

@@ -1,6 +1,6 @@
export const viewModes: ViewMode[] = [ export const viewModes: ViewMode[] = [
{ name: 'Note', icon: 'fas fa-sticky-note fa-fw' }, { name: 'Note', icon: 'fas fa-sticky-note fa-fw' },
{ name: 'List', icon: 'fas fa-list fa-fw' }, { name: 'List', icon: 'fas fa-list fa-fw' },
{ name: 'Mindmap', icon: 'fas fa-project-diagram fa-fw' } { name: 'Mindmap', icon: 'fas fa-project-diagram fa-fw' }
] ]
export const activeViewMode = ref<ViewMode>(viewModes[0]) export const activeViewMode = ref<ViewMode>(viewModes[0])

View File

@@ -1,3 +1,4 @@
import { Capacitor } from '@capacitor/core'
import { createApp } from 'vue' import { createApp } from 'vue'
import '@/style.scss' import '@/style.scss'
import '@fortawesome/fontawesome-free/css/all.min.css' import '@fortawesome/fontawesome-free/css/all.min.css'
@@ -7,14 +8,15 @@ import { initializeFirebase } from '@/composables/useFirebase'
import { StatusBar, Style } from '@capacitor/status-bar' import { StatusBar, Style } from '@capacitor/status-bar'
import { Keyboard } from '@capacitor/keyboard' import { Keyboard } from '@capacitor/keyboard'
StatusBar.setStyle({ style: Style.Dark }) if (Capacitor.isNativePlatform()) {
Keyboard.setAccessoryBarVisible({ isVisible: true }) StatusBar.setStyle({ style: Style.Dark })
Keyboard.setAccessoryBarVisible({ isVisible: true })
}
initializeFirebase() initializeFirebase()
const isDark = usePreferredDark() const isDark = usePreferredDark()
const favicon = computed<string>(() => const favicon = computed<string>(() =>
isDark.value ? '/contexted-white.ico' : '/contexted-black.ico' isDark.value ? '/contexted-white.ico' : '/contexted-black.ico'
) )
useFavicon(favicon) useFavicon(favicon)

View File

@@ -5,92 +5,92 @@
@import '@fontsource/source-sans-pro/300'; @import '@fontsource/source-sans-pro/300';
* { * {
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
html { html {
// height: -webkit-fill-available; // height: -webkit-fill-available;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
body { body {
// min-height: 100vh; // min-height: 100vh;
// min-height: -webkit-fill-available; // min-height: -webkit-fill-available;
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
position: fixed; position: fixed;
transform: translateZ(0); transform: translateZ(0);
touch-action: manipulation; touch-action: manipulation;
-webkit-user-drag: none; -webkit-user-drag: none;
-ms-content-zooming: none; -ms-content-zooming: none;
font-family: 'Source Sans Pro', sans-serif; font-family: 'Source Sans Pro', sans-serif;
// overflow-y: scroll; // overflow-y: scroll;
@apply flex flex-col bg-gray-100; @apply flex flex-col bg-gray-100;
#app { #app {
@apply absolute bottom-0 left-0 right-0 top-0 flex flex-grow flex-col overflow-hidden; @apply absolute bottom-0 left-0 right-0 top-0 flex flex-grow flex-col overflow-hidden;
} }
} }
p:not(:last-child) { p:not(:last-child) {
@apply mb-2; @apply mb-2;
margin-top: 0 !important; margin-top: 0 !important;
} }
p:last-child { p:last-child {
@apply my-0; @apply my-0;
} }
.ck-body-wrapper { .ck-body-wrapper {
height: 0; height: 0;
} }
.ck-content { .ck-content {
padding: 0 !important; padding: 0 !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
line-height: 2rem; line-height: 2rem;
a[data-contexted-link='true'] { a[data-contexted-link='true'] {
@apply cursor-pointer font-semibold text-primary hover:bg-gray-200; @apply cursor-pointer font-semibold text-primary hover:bg-gray-200;
} }
&.ck.ck-editor__editable_inline > * { &.ck.ck-editor__editable_inline > * {
margin: 0 !important; margin: 0 !important;
} }
ol, ol,
ul { ul {
@apply my-2 ps-8; @apply my-2 ps-8;
} }
ol { ol {
@apply list-decimal; @apply list-decimal;
} }
ul { ul {
@apply list-disc; @apply list-disc;
} }
h1, h1,
h2, h2,
h3, h3,
h4 { h4 {
@apply font-semibold; @apply font-semibold;
} }
h1 { h1 {
@apply text-3xl; @apply text-3xl;
} }
h2 { h2 {
@apply text-2xl; @apply text-2xl;
} }
h3 { h3 {
@apply text-xl; @apply text-xl;
} }
h4 { h4 {
@apply text-lg; @apply text-lg;
} }
} }
:root { :root {
--safe-area-top: env(safe-area-inset-top); --safe-area-top: env(safe-area-inset-top);
--safe-area-right: env(safe-area-inset-right); --safe-area-right: env(safe-area-inset-right);
--safe-area-bottom: env(safe-area-inset-bottom); --safe-area-bottom: env(safe-area-inset-bottom);
--safe-area-left: env(safe-area-inset-left); --safe-area-left: env(safe-area-inset-left);
} }

86
src/types.d.ts vendored
View File

@@ -1,48 +1,48 @@
declare global { declare global {
interface BaseNote { interface BaseNote {
id: string id: string
title: string title: string
content: string content: string
created: number created: number
modified: number modified: number
isRoot: boolean isRoot: boolean
}
interface Note extends BaseNote {
wordCount: number
}
interface BaseNotes {
[noteId: string]: BaseNote
}
interface ViewMode {
name: string
icon: string
}
interface AutocompleteEvent {
position?: any
autocompleteText?: string
domElement?: HTMLElement
show: boolean
}
interface NoteRelations {
id: string
to: string[]
from: string[]
}
interface NotesRelations {
[noteId: string]: {
to: string[]
from: string[]
} }
}
type ClientKey = string interface Note extends BaseNote {
type EncryptedEncryptionKey = string wordCount: number
type EncryptionKey = string }
interface BaseNotes {
[noteId: string]: BaseNote
}
interface ViewMode {
name: string
icon: string
}
interface AutocompleteEvent {
position?: any
autocompleteText?: string
domElement?: HTMLElement
show: boolean
}
interface NoteRelations {
id: string
to: string[]
from: string[]
}
interface NotesRelations {
[noteId: string]: {
to: string[]
from: string[]
}
}
type ClientKey = string
type EncryptedEncryptionKey = string
type EncryptionKey = string
} }
export {} export {}

View File

@@ -4,20 +4,20 @@ const encryptionPrefix = 'contexted|'
const salt = 'salt' const salt = 'salt'
export const calculateClientKey = (passphrase: string): ClientKey => { export const calculateClientKey = (passphrase: string): ClientKey => {
return CryptoJS.PBKDF2(passphrase, salt, { keySize: 256 / 32 }).toString() return CryptoJS.PBKDF2(passphrase, salt, { keySize: 256 / 32 }).toString()
} }
export const decrypt = (encryptedMessage: string, key: string): string => { export const decrypt = (encryptedMessage: string, key: string): string => {
const decryptedMessage = CryptoJS.AES.decrypt(encryptedMessage, key).toString(CryptoJS.enc.Utf8) const decryptedMessage = CryptoJS.AES.decrypt(encryptedMessage, key).toString(CryptoJS.enc.Utf8)
if (!decryptedMessage.startsWith(encryptionPrefix)) if (!decryptedMessage.startsWith(encryptionPrefix))
throw new Error("Message doesn't have valid encryption") throw new Error("Message doesn't have valid encryption")
return decryptedMessage.substring(encryptionPrefix.length) return decryptedMessage.substring(encryptionPrefix.length)
} }
export const encrypt = (unencryptedMessage: string, key: string): string => { export const encrypt = (unencryptedMessage: string, key: string): string => {
return CryptoJS.AES.encrypt(encryptionPrefix + unencryptedMessage, key).toString() return CryptoJS.AES.encrypt(encryptionPrefix + unencryptedMessage, key).toString()
} }
export const generateEncryptionKey = () => { export const generateEncryptionKey = () => {
return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex) return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex)
} }

View File

@@ -1,24 +1,24 @@
import shortid from 'shortid' import shortid from 'shortid'
export const defaultNotes: BaseNote[] = [ export const defaultNotes: BaseNote[] = [
{ {
isRoot: true, isRoot: true,
title: 'Your first note', title: 'Your first note',
content: `Contexted is a **relational note-taking app**. Use it as your personal knowledge base, research assistent or just to map out thoughts.\n\n content: `Contexted is a **relational note-taking app**. Use it as your personal knowledge base, research assistent or just to map out thoughts.\n\n
# How does it work? # How does it work?
* Create a new note by typing words between [[brackets]] * Create a new note by typing words between [[brackets]]
* Click on **Mindmap mode** in the menu left to visualize your notes * Click on **Mindmap mode** in the menu left to visualize your notes
\n \n
Let's get started!` Let's get started!`
}, },
{ {
title: 'brackets', title: 'brackets',
content: `If you type square brackets around text a link is created automatically. Like magic!`, content: `If you type square brackets around text a link is created automatically. Like magic!`,
isRoot: false isRoot: false
} }
].map((note) => ({ ].map((note) => ({
id: shortid.generate(), id: shortid.generate(),
created: new Date().getTime(), created: new Date().getTime(),
modified: new Date().getTime(), modified: new Date().getTime(),
...note ...note
})) }))

View File

@@ -1,20 +1,20 @@
import { formatDistance, format, isToday } from 'date-fns' import { formatDistance, format, isToday } from 'date-fns'
export const formatDate = (date: Date | number): string => { export const formatDate = (date: Date | number): string => {
const dateToFormat = ['number', 'string'].includes(typeof date) ? new Date(date) : date const dateToFormat = ['number', 'string'].includes(typeof date) ? new Date(date) : date
const dateDistanceInWords = formatDistance(dateToFormat, new Date()) + ' ago' const dateDistanceInWords = formatDistance(dateToFormat, new Date()) + ' ago'
return isToday(date) ? dateDistanceInWords : format(date, "d MMMM 'at' HH:mm ") return isToday(date) ? dateDistanceInWords : format(date, "d MMMM 'at' HH:mm ")
} }
export const getAllMatches = (regex: RegExp, input: string): RegExpExecArray[] => { export const getAllMatches = (regex: RegExp, input: string): RegExpExecArray[] => {
const matches = [] const matches = []
let m let m
do { do {
m = regex.exec(input) m = regex.exec(input)
// console.log(m) // console.log(m)
if (m) matches.push(m) if (m) matches.push(m)
} while (m) } while (m)
return matches return matches
} }
export const windowIsMobile = () => window.innerWidth < 640 export const windowIsMobile = () => window.innerWidth < 640

View File

@@ -3,40 +3,40 @@ import DOMPurify from 'dompurify'
import Turndown from 'turndown' import Turndown from 'turndown'
export function mdToHtml(mdText: string): string { export function mdToHtml(mdText: string): string {
const renderer = new marked.Renderer() const renderer = new marked.Renderer()
const html = DOMPurify.sanitize(marked.parse(mdText, { renderer })) const html = DOMPurify.sanitize(marked.parse(mdText, { renderer }))
const re = /(\[\[)(.*?)(\]\])/g const re = /(\[\[)(.*?)(\]\])/g
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('p, b, u, i, li, h1, h2, h3').forEach((element) => { doc.querySelectorAll('p, b, u, i, li, h1, h2, h3').forEach((element) => {
element.innerHTML = element.innerHTML.replace(re, (_, p1, p2, p3) => { element.innerHTML = element.innerHTML.replace(re, (_, p1, p2, p3) => {
return `${p1}<a data-contexted-link="true">${p2}</a>${p3}` return `${p1}<a data-contexted-link="true">${p2}</a>${p3}`
})
}) })
}) return doc.body.innerHTML
return doc.body.innerHTML
} }
export function htmlToMd(htmlText: string): string { export function htmlToMd(htmlText: string): string {
const turndown = new Turndown({ headingStyle: 'atx' }) const turndown = new Turndown({ headingStyle: 'atx' })
const escapes = [ const escapes = [
[/\\/g, '\\\\'], [/\\/g, '\\\\'],
[/\*/g, '\\*'], [/\*/g, '\\*'],
[/^-/g, '\\-'], [/^-/g, '\\-'],
[/^\+ /g, '\\+ '], [/^\+ /g, '\\+ '],
[/^(=+)/g, '\\$1'], [/^(=+)/g, '\\$1'],
[/^(#{1,6}) /g, '\\$1 '], [/^(#{1,6}) /g, '\\$1 '],
[/`/g, '\\`'], [/`/g, '\\`'],
[/^~~~/g, '\\~~~'], [/^~~~/g, '\\~~~'],
// [/\[/g, '\\['], // [/\[/g, '\\['],
// [/\]/g, '\\]'], // [/\]/g, '\\]'],
[/^>/g, '\\>'], [/^>/g, '\\>'],
[/_/g, '\\_'], [/_/g, '\\_'],
[/^(\d+)\. /g, '$1\\. '] [/^(\d+)\. /g, '$1\\. ']
] ]
turndown.escape = (string) => turndown.escape = (string) =>
escapes.reduce((accumulator, escape) => { escapes.reduce((accumulator, escape) => {
return accumulator.replace(escape[0], escape[1] as string) return accumulator.replace(escape[0], escape[1] as string)
}, string) }, string)
const md = turndown.turndown(htmlText) const md = turndown.turndown(htmlText)
return md return md
} }

View File

@@ -38,12 +38,12 @@ export default {
themes: [ themes: [
{ {
contexted: { contexted: {
...require('daisyui/src/theming/themes')['[data-theme=light]'], ...require('daisyui/src/theming/themes')['light'],
primary, primary,
secondary, secondary,
'--btn-text-case': 'uppercase' // set default text transform for buttons '--btn-text-case': 'uppercase', // set default text transform for buttons
// accent: '#37CDBE', // accent: '#37CDBE',
// neutral: '#F7F7F7', neutral: primary,
// 'base-100': '#FFFFFF', // 'base-100': '#FFFFFF',
// info: '#3ABFF8', // info: '#3ABFF8',
// success: '#36D399', // success: '#36D399',