Compare commits

..

87 Commits
master ... auth

Author SHA1 Message Date
Marco Crapts
cfa1987c3a custom auth 2023-06-02 16:00:39 +02:00
33737167b4 run prettier 2023-06-02 00:04:18 +02:00
c21538c09f fix keyboard and haptics 2023-06-01 23:17:41 +02:00
1f3a79bc99 fix statusbar 2023-06-01 22:08:55 +02:00
d6c55da727 fix table layout 2023-06-01 22:02:54 +02:00
6e725ec966 fix iphone padding 2023-06-01 21:32:25 +02:00
Marco Crapts
ccb3c98fe8 fix iphone padding 2023-06-01 21:31:18 +02:00
Marco Crapts
fb07da9599 update ios project 2023-06-01 17:41:41 +02:00
Marco Crapts
75a87378ee daisyUI 3.0 2023-06-01 13:42:35 +02:00
b17ce32b4d add Android app link 2023-06-01 00:21:14 +02:00
a837305f2a fix list view 2023-05-31 21:26:59 +02:00
bbc8010da3 redirect signin 2023-05-31 21:14:59 +02:00
99bc820a71 haptics 2023-05-31 20:14:46 +02:00
af41c3533f native dialog on android & ios 2023-05-31 00:29:10 +02:00
5f5987bf5e create ios and android targets 2023-05-30 23:06:11 +02:00
2b15178184 Merge branch 'master' into capacitor 2023-05-30 22:54:27 +02:00
c3459f689a persistent local cache 2023-05-30 22:54:14 +02:00
65465f79e6 update .env 2023-05-30 00:56:29 +02:00
f1ddb2d736 add Capacitor 2023-05-30 00:45:45 +02:00
a5ef1c5333 public Firebase keys 2023-05-30 00:19:34 +02:00
c21effa5fa small update 2023-05-29 22:10:27 +02:00
e3f11f2b35 ui improvements 2023-05-29 01:54:59 +02:00
2959fbd811 delete account 2023-05-29 01:48:46 +02:00
7708f25caa delete account ui functionality 2023-05-28 23:29:42 +02:00
8e9af01147 delete account ui functionality 2023-05-28 23:21:59 +02:00
bcb956ae6e slight refactoring 2023-05-28 22:23:41 +02:00
13d6364ffe enable/disable e2e encryption 2023-05-28 21:45:47 +02:00
37e677ec6a settings modal 2023-05-28 18:35:27 +02:00
28f2f9a9ca refactor to UI components 2023-05-26 01:44:20 +02:00
c76bf3f6d8 refactor to UI components 2023-05-26 00:50:19 +02:00
Marco Crapts
0f48494469 fix tailwind css 2023-05-26 19:29:52 +02:00
Marco Crapts
41390233c1 more ui components 2023-05-26 19:21:27 +02:00
Marco Crapts
7c1e74ff39 more ui components 2023-05-26 17:32:43 +02:00
Marco Crapts
3e6b9414f4 refactor to ui components 2023-05-26 16:43:12 +02:00
906121882b collapse sidebar on resize 2023-05-25 22:56:56 +02:00
f6e5d5ca4f delete notes & sync to firebase 2023-05-25 22:29:40 +02:00
5a4bba2dcd passphrase validation 2023-05-25 20:29:24 +02:00
Marco Crapts
1ab3db6cce fix overlay 2023-05-25 17:10:57 +02:00
Marco Crapts
ee8afdf2a7 fix layout 2023-05-25 16:41:05 +02:00
Marco Crapts
f059cc0291 fix layout 2023-05-25 10:50:40 +02:00
4becbf41a9 fix click outside 2023-05-25 00:11:18 +02:00
cf8d965e64 store settings in localstorage 2023-05-24 23:30:28 +02:00
79f81cb83f improve sidebar mobile 2023-05-23 23:01:02 +02:00
117fff9b02 click outside sidebar collapse on mobile 2023-05-23 22:49:36 +02:00
32bdacda88 less recent items 2023-05-23 22:13:25 +02:00
b2c1f7d11d tabs 2023-05-23 22:12:23 +02:00
fc9d8d1023 update search results 2023-05-23 21:53:55 +02:00
f82b28b896 update search results 2023-05-23 21:16:48 +02:00
Marco Crapts
3a40c95496 refactor colors 2023-05-23 13:29:00 +02:00
Marco Crapts
f102dd1ff0 better animated sidebar 2023-05-23 13:18:43 +02:00
6f19ee94d1 passphrase prompt 2023-05-23 00:44:51 +02:00
6a53d9fd58 improve loading 2023-05-22 20:56:54 +02:00
b7e5da2354 decrypt notes 2023-05-22 20:48:53 +02:00
Marco Crapts
d45ceb9b41 cleanup css 2023-05-22 10:58:29 +02:00
Marco Crapts
24dc9482da fix stretch + default notes 2023-05-22 10:02:52 +02:00
dd2a3d91ca try catch json parse 2023-05-22 09:08:06 +02:00
4d4938a0ad add word count to note list 2023-05-22 00:27:06 +02:00
021e0f3eb4 update signout dialog 2023-05-22 00:23:25 +02:00
16c92ed33f sync with local storage 2023-05-22 00:19:46 +02:00
87c3ff52ef default sidebar collapse mobile 2023-05-21 13:56:42 +02:00
704c955278 fix jumpy safari 2023-05-21 13:28:02 +02:00
e2386ef681 fix note title too wide 2023-05-21 12:00:20 +02:00
ba0b6b5042 single firebaseui import 2023-05-21 11:28:19 +02:00
07eb24006d rename close to cancel 2023-05-21 01:52:08 +02:00
65b641866e add modal titles 2023-05-20 15:41:37 +02:00
16c93b2d10 signout icon 2023-05-20 15:33:51 +02:00
c3cfc11f5f dropdown menu 2023-05-20 15:26:15 +02:00
cf00a57a7d skeleton loader 2023-05-20 15:00:55 +02:00
f61be632a0 skeleton loader 2023-05-20 14:54:41 +02:00
7e7bb41a27 show auth modal in case of redirect 2023-05-20 12:38:55 +02:00
f9cbc88303 fix auth 2023-05-20 12:14:48 +02:00
1f38d6a1ac auth loader 2023-05-20 07:13:06 +02:00
b0224826ec working auth 2023-05-20 06:53:47 +02:00
aa02e66245 add auth modal 2023-05-20 04:05:36 +02:00
b52ae59817 create link without autocomplete 2023-05-20 02:35:11 +02:00
0ac2e00399 add firebase 2023-05-19 23:38:43 +02:00
14018e7606 mindmap 2023-05-19 22:07:58 +02:00
e802ad289c listview 2023-05-19 19:12:55 +02:00
Marco Crapts
35b016449a update project config to match official create vue recommendations 2023-05-19 14:16:44 +02:00
Marco Crapts
0619707054 update list view 2023-05-19 11:00:18 +02:00
d45045d63f listview component 2023-05-17 07:43:53 +02:00
22017f3e8b improve scrolling / layout 2023-05-17 07:24:26 +02:00
6c52785597 flex-grow instead of flex-1 2023-05-17 05:27:59 +02:00
9765806dca add favicons 2023-05-17 04:20:08 +02:00
15147a547d create note on click if does not exist 2023-05-17 03:47:48 +02:00
54af4cdc7e improved autocomplete 2023-05-17 03:42:10 +02:00
Marco Crapts
4a47f6d0a4 rename types to types.d.ts & fix Contexted logo 2023-05-17 15:27:12 +02:00
99 changed files with 2615 additions and 3080 deletions

View File

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

View File

@@ -1,11 +1,2 @@
pretty:
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,11 +9,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-firebase-authentication')
implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
}

View File

@@ -1,40 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.1 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.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -2,9 +2,6 @@
include ':capacitor-android'
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'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
@@ -14,8 +11,5 @@ project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-keyboard'
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'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

View File

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

View File

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

View File

@@ -15,7 +15,6 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
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 */
/* Begin PBXFileReference section */
@@ -28,7 +27,6 @@
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>"; };
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; };
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>"; };
@@ -76,7 +74,6 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
A9A186E52A2FB826009CBA16 /* GoogleService-Info.plist */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
@@ -164,7 +161,6 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
A9A186E62A2FB826009CBA16 /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -351,7 +347,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5XQS3G6YV7;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -372,7 +367,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5XQS3G6YV7;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -1,38 +0,0 @@
<?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>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Contexted</string>
<string>Contexted</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -18,19 +18,6 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<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>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>

View File

@@ -11,29 +11,17 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :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 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
end
target 'App' do
capacitor_pods
# Add your Pods here
pod 'CapacitorFirebaseAuthentication/Google', :path => '../../node_modules/@capacitor-firebase/authentication'
end
post_install do |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

View File

@@ -1,96 +1,24 @@
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):
- CapacitorCordova
- CapacitorCordova (5.0.4)
- CapacitorDialog (5.0.2):
- 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):
- Capacitor
- CapacitorKeyboard (5.0.2):
- Capacitor
- CapacitorSplashScreen (5.0.2):
- Capacitor
- CapacitorStatusBar (5.0.2):
- 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:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "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`)"
- "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
SPEC REPOS:
trunk:
- AppAuth
- FirebaseAppCheckInterop
- FirebaseAuth
- FirebaseCore
- FirebaseCoreInternal
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- PromisesObjC
EXTERNAL SOURCES:
Capacitor:
:path: "../../node_modules/@capacitor/ios"
@@ -98,37 +26,21 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios"
CapacitorDialog:
:path: "../../node_modules/@capacitor/dialog"
CapacitorFirebaseAuthentication:
:path: "../../node_modules/@capacitor-firebase/authentication"
CapacitorHaptics:
:path: "../../node_modules/@capacitor/haptics"
CapacitorKeyboard:
:path: "../../node_modules/@capacitor/keyboard"
CapacitorSplashScreen:
:path: "../../node_modules/@capacitor/splash-screen"
CapacitorStatusBar:
:path: "../../node_modules/@capacitor/status-bar"
SPEC CHECKSUMS:
AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570
Capacitor: d3d4463573438b9fa65326d1f3549da6f4c21634
CapacitorCordova: b1fe6bf1f36974a8e4a9044b342d22d49c0996d6
CapacitorDialog: 01c49f7f4b37e7ad59e38fd317a6e5f006f23cdc
CapacitorFirebaseAuthentication: f2e3c2a7488b87078025855588670840f93a721e
CapacitorHaptics: 864585542a435bd41eaabf7f30d9ff5ec03024d3
CapacitorKeyboard: e628d4e66d621c69e449945ebabded17c5b9c2e8
CapacitorSplashScreen: bd2a056394ba0b8807e7bb3e746424f67c426e03
CapacitorStatusBar: 48f2899f6846cc7d8431b251ebfc58e1c10e3d58
FirebaseAppCheckInterop: 7d3521f56872cf74a01792c0a095a30e054ff6ae
FirebaseAuth: 28e6fff787467cd15ab51c8c7aa904003b2f57aa
FirebaseCore: d027ff503d37edb78db98429b11f580a24a7df2a
FirebaseCoreInternal: 971029061d326000d65bfdc21f5502c75c8b0893
GoogleSignIn: b232380cf495a429b8095d3178a8d5855b42e842
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae
GTMSessionFetcher: e8647203b65cee28c5f73d0f473d096653945e72
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
PODFILE CHECKSUM: 9a19ff50409d024bca91266c62454036ebb27258
PODFILE CHECKSUM: b469cdc64593e190968b9aa15066224f10938107
COCOAPODS: 1.12.1

337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,22 +2,21 @@
import { activeNote, updateNote, notes, activeNotesSource, getNotes } from '@/composables/useNotes'
import { viewModes, activeViewMode } from '@/composables/useViewMode'
import {
getClientKey,
getEncryptionKey,
encryptionKey,
setClientKey,
passphraseRequired
getClientKey,
getEncryptionKey,
encryptionKey,
setClientKey,
passphraseRequired
} from '@/composables/useEncryption'
import { initializeSettings } from '@/composables/useSettings'
import { windowIsMobile } from '@/utils/helpers'
import SideBar from '@/components/SideBar.vue'
import firebase from 'firebase/compat/app'
import * as firebaseui from 'firebaseui'
import { useWindowSize } from '@vueuse/core'
import { SplashScreen } from '@capacitor/splash-screen'
initializeSettings()
onMounted(() => SplashScreen.hide())
const sideBarCollapsed = ref<boolean>(windowIsMobile())
const { width } = useWindowSize()
@@ -27,33 +26,33 @@ watch(width, () => (sideBarCollapsed.value = windowIsMobile()))
// const ListView = defineAsyncComponent(() => import('@/components/ViewModes/ListView.vue'))
// const Mindmap = defineAsyncComponent(() => import('@/components/ViewModes/Mindmap.vue'))
// const firebaseAuthUI =
// firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(firebase.auth())
// provide('firebaseAuthUI', firebaseAuthUI)
const firebaseAuthUI =
firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(firebase.auth())
provide('firebaseAuthUI', firebaseAuthUI)
watch(
[activeNotesSource, encryptionKey],
() => {
if (activeNotesSource.value === 'firebase') {
getClientKey()
getEncryptionKey()
}
getNotes()
},
{ immediate: true }
[activeNotesSource, encryptionKey],
() => {
if (activeNotesSource.value === 'firebase') {
getClientKey()
getEncryptionKey()
}
getNotes()
},
{ immediate: true }
)
const passphrase = ref('')
const passphraseValid = ref<boolean>()
const submitPassphrase = (close: () => void) => {
const setClientKeyResult = setClientKey(passphrase.value)
passphraseValid.value = setClientKeyResult
if (passphraseValid.value) close()
const setClientKeyResult = setClientKey(passphrase.value)
passphraseValid.value = setClientKeyResult
if (passphraseValid.value) close()
}
const loading = computed(
() => notes.value.length === 0 || passphraseRequired.value || !activeNotesSource.value
() => notes.value.length === 0 || passphraseRequired.value || !activeNotesSource.value
)
provide('loading', loading)
@@ -62,90 +61,90 @@ const topBarHeightWithSafeArea = computed(() => `calc(${topBarHeight}px + var(--
</script>
<template>
<TopBar
:side-bar-collapsed="sideBarCollapsed"
:height="topBarHeight"
:style="{ height: topBarHeightWithSafeArea }"
@toggle-side-bar="sideBarCollapsed = !sideBarCollapsed"
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="mx-auto flex w-full max-w-app flex-grow pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]"
<TopBar
:side-bar-collapsed="sideBarCollapsed"
:height="topBarHeight"
:style="{ height: topBarHeightWithSafeArea }"
@toggle-side-bar="sideBarCollapsed = !sideBarCollapsed"
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="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'"
>
<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 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>
<div class="flex h-full w-full 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>
<template #actions="{ close }">
<UIButton color="primary" size="sm" @click="submitPassphrase(close)">Submit</UIButton>
</template>
</UIModal>
<SkeletonNote v-else />
</div>
</main>
</div>
<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>
<style scoped>
.sidebar-enter-from,
.sidebar-leave-to {
@apply max-sm:-translate-x-full;
@apply max-sm:-translate-x-full;
}
.overlay-enter-from,
.overlay-leave-to {
@apply opacity-0;
@apply opacity-0;
}
main {
contain: size layout style;
contain: size layout style;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -15,228 +15,226 @@ const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'
const INPUT_EVENT_DEBOUNCE_WAIT = 300
export interface CKEditorComponentData {
instance: Editor | null
lastEditorData: string | null
instance: Editor | null
lastEditorData: string | null
}
export default defineComponent({
name: 'Ckeditor',
name: 'Ckeditor',
model: {
prop: 'modelValue',
event: 'update:modelValue'
model: {
prop: 'modelValue',
event: 'update:modelValue'
},
props: {
editor: {
type: Function as unknown as PropType<{
create(...args: any): Promise<Editor>
}>,
required: true
},
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
}
config: {
type: Object as PropType<EditorConfig>,
default: () => ({})
},
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
}
modelValue: {
type: String,
default: ''
},
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.
disabled(readOnlyMode) {
if (readOnlyMode) {
this.instance!.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
} else {
this.instance!.disableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
}
tagName: {
type: String,
default: 'div'
},
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.')
}
disabled: {
type: Boolean,
default: false
},
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)
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.
disabled(readOnlyMode) {
if (readOnlyMode) {
this.instance!.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
} else {
this.instance!.disableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
}
},
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,
{ 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)
}
})

View File

@@ -6,184 +6,178 @@ import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'
const HIGHLIGHT_CLASS = 'ck-link_selected'
export default class ContextedLinkEditing extends Plugin {
init() {
this._defineSchema() // ADDED
this._defineConverters() // ADDED
this._addContextedKeyHandler()
const twoStepCaretMovementPlugin = this.editor.plugins.get(TwoStepCaretMovement)
twoStepCaretMovementPlugin.registerAttribute('contextedLink')
inlineHighlight(this.editor, 'contextedLink', 'a', HIGHLIGHT_CLASS)
this.editor.commands.add('autocomplete', new AttributeCommand(this.editor, 'autocomplete'))
}
afterInit() {
this._addAutocomplete()
}
_defineSchema() {
// ADDED
const schema = this.editor.model.schema
init() {
this._defineSchema() // ADDED
this._defineConverters() // ADDED
this._addContextedKeyHandler()
const twoStepCaretMovementPlugin = this.editor.plugins.get(TwoStepCaretMovement)
twoStepCaretMovementPlugin.registerAttribute('contextedLink')
inlineHighlight(this.editor, 'contextedLink', 'a', HIGHLIGHT_CLASS)
this.editor.commands.add('autocomplete', new AttributeCommand(this.editor, 'autocomplete'))
}
afterInit() {
this._addAutocomplete()
}
_defineSchema() {
// ADDED
const schema = this.editor.model.schema
// Extend the text node's schema to accept the abbreviation attribute.
schema.extend('$text', {
allowAttributes: ['contextedLink', 'autocomplete']
// Extend the text node's schema to accept the abbreviation attribute.
schema.extend('$text', {
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() {
// ADDED
const conversion = this.editor.conversion
}
})
conversion.for('upcast').elementToAttribute({
view: {
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
}
// 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
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))
)
return writer.createAttributeElement('a', {
'data-contexted-link': modelAttributeValue
})
}
})
conversion.for('upcast').elementToAttribute({
view: {
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
}
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 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' }
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' }
)
}
}
function getNodePosition(editor: any, modelPosition: any) {
try {
const mapper = editor.editing.mapper
const viewPosition = mapper.toViewPosition(modelPosition)
const viewRange = editor.editing.view.createRange(viewPosition)
const domConverter = editor.editing.view.domConverter
const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)).pop()
return rangeRects
} catch (e) {
console.log(e)
}
try {
const mapper = editor.editing.mapper
const viewPosition = mapper.toViewPosition(modelPosition)
const viewRange = editor.editing.view.createRange(viewPosition)
const domConverter = editor.editing.view.domConverter
const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)).pop()
return rangeRects
} catch (e) {
console.log(e)
}
}
// function testOutputToRanges(start: any, arrays: any[], model: any) {
@@ -198,43 +192,40 @@ function getNodePosition(editor: any, modelPosition: any) {
// }
function getTextAfterCode(range: any, model: any) {
let start = range.start
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.
if (!(node.is('$text') || node.is('$textProxy')) || node.getAttribute('code')) {
start = model.createPositionAfter(node)
return ''
}
return rangeText + node.data
}, '')
return { text, range: model.createRange(start, range.end) }
let start = range.start
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.
if (!(node.is('$text') || node.is('$textProxy')) || node.getAttribute('code')) {
start = model.createPositionAfter(node)
return ''
}
return rangeText + node.data
}, '')
return { text, range: model.createRange(start, range.end) }
}
function fireAutocompleteEvent(editor: any, show: boolean, autocompleteNode?: any) {
let event: AutocompleteEvent
if (show && autocompleteNode) {
const view = editor.editing.view
const viewPosition = view.document.selection.focus
const viewNode = viewPosition?.parent.parent
const domElement = viewNode
? (view.domConverter.mapViewToDom(viewNode) as HTMLElement)
: undefined
event = {
position: getNodePosition(
editor,
editor.model.createPositionFromPath(
autocompleteNode.root,
autocompleteNode.getPath()
)
),
autocompleteText: autocompleteNode.data,
domElement,
show: true
}
} else {
event = {
show: false
}
let event: AutocompleteEvent
if (show && autocompleteNode) {
const view = editor.editing.view
const viewPosition = view.document.selection.focus
const viewNode = viewPosition?.parent.parent
const domElement = viewNode
? (view.domConverter.mapViewToDom(viewNode) as HTMLElement)
: undefined
event = {
position: getNodePosition(
editor,
editor.model.createPositionFromPath(autocompleteNode.root, autocompleteNode.getPath())
),
autocompleteText: autocompleteNode.data,
domElement,
show: true
}
editor.model.document.fire('contextedLinkAutocomplete', event)
} else {
event = {
show: false
}
}
editor.model.document.fire('contextedLinkAutocomplete', event)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,80 +6,80 @@ import { activeViewMode } from '@/composables/useViewMode'
const loading = inject<boolean>('loading')
const props = defineProps<{
viewModes: ViewMode[]
activeViewMode: ViewMode
viewModes: ViewMode[]
activeViewMode: ViewMode
}>()
const emit = defineEmits<{
setViewMode: [viewMode: ViewMode]
collapse: [collapse: boolean]
setViewMode: [viewMode: ViewMode]
collapse: [collapse: boolean]
}>()
const setActiveNote = (noteId: string | undefined) => {
emit('collapse', windowIsMobile())
baseSetActiveNote(noteId)
emit('collapse', windowIsMobile())
baseSetActiveNote(noteId)
}
const setViewMode = (viewMode: ViewMode) => {
emit('collapse', windowIsMobile())
emit('setViewMode', viewMode)
emit('collapse', windowIsMobile())
emit('setViewMode', viewMode)
}
</script>
<template>
<div
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"
>
<SideBarMenu>
<template #header>Root note</template>
<template #items>
<SideBarMenuItem
icon="fas fa-fw fa-home"
@click="setActiveNote(rootNote?.id)"
:title="rootNote?.title"
v-if="!loading"
>
{{ rootNote?.title }}
</SideBarMenuItem>
<SkeletonSidebarItem v-else />
</template>
</SideBarMenu>
<SideBarMenu>
<template #header>View mode</template>
<template #items>
<template v-if="!loading">
<SideBarMenuItem
v-for="viewMode in props.viewModes"
:key="viewMode.name"
:icon="viewMode.icon"
:active="viewMode.name === activeViewMode.name"
@click="setViewMode(viewMode)"
>
{{ viewMode.name }}
</SideBarMenuItem>
</template>
<SkeletonSidebarItem :n="3" v-else />
</template>
</SideBarMenu>
<SideBarMenu>
<template #header>
<i class="far fa-clock fa-fw mr-2" />
Recent notes
</template>
<template #items>
<template v-if="!loading">
<SideBarMenuItem
v-for="note in notes.slice(-5)"
:key="note.id"
icon="far fa-file-alt fa-fw"
@click="setActiveNote(note.id)"
:title="rootNote?.title"
>
{{ note.title }}
</SideBarMenuItem>
</template>
<SkeletonSidebarItem v-else :n="5" />
</template>
</SideBarMenu>
</div>
<div
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"
>
<SideBarMenu>
<template #header>Root note</template>
<template #items>
<SideBarMenuItem
icon="fas fa-fw fa-home"
@click="setActiveNote(rootNote?.id)"
:title="rootNote?.title"
v-if="!loading"
>
{{ rootNote?.title }}
</SideBarMenuItem>
<SkeletonSidebarItem v-else />
</template>
</SideBarMenu>
<SideBarMenu>
<template #header>View mode</template>
<template #items>
<template v-if="!loading">
<SideBarMenuItem
v-for="viewMode in props.viewModes"
:key="viewMode.name"
:icon="viewMode.icon"
:active="viewMode.name === activeViewMode.name"
@click="setViewMode(viewMode)"
>
{{ viewMode.name }}
</SideBarMenuItem>
</template>
<SkeletonSidebarItem :n="3" v-else />
</template>
</SideBarMenu>
<SideBarMenu>
<template #header>
<i class="far fa-clock fa-fw mr-2" />
Recent notes
</template>
<template #items>
<template v-if="!loading">
<SideBarMenuItem
v-for="note in notes.slice(-5)"
:key="note.id"
icon="far fa-file-alt fa-fw"
@click="setActiveNote(note.id)"
:title="rootNote?.title"
>
{{ note.title }}
</SideBarMenuItem>
</template>
<SkeletonSidebarItem v-else :n="5" />
</template>
</SideBarMenu>
</div>
</template>

View File

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

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
const props = defineProps<{
icon?: string
active?: boolean
icon?: string
active?: boolean
}>()
</script>
<template>
<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="props.active ? 'font-bold text-primary' : 'text-secondary'"
>
<i :class="props.icon" class="mr-2" v-if="props.icon"></i>
<slot></slot>
</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="props.active ? 'font-bold text-primary' : 'text-secondary'"
>
<i :class="props.icon" class="mr-2" v-if="props.icon"></i>
<slot></slot>
</a>
</template>

View File

@@ -1,30 +1,30 @@
<template>
<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="h-[2.25rem] w-[40px] 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 class="flex h-full w-full animate-pulse flex-col">
<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] 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>
</template>
<style scoped>
.bg-secondary {
@apply bg-secondary/25;
@apply bg-secondary/25;
}
</style>

View File

@@ -1,22 +1,18 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
n?: number
}>(),
{ n: 1 }
defineProps<{
n?: number
}>(),
{ n: 1 }
)
</script>
<template>
<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="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>
</template>
<style scoped>
.bg-secondary {
@apply bg-secondary/25;
@apply bg-secondary/25;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<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-[44px] rounded bg-white/10" />
</div>
<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-[44px] rounded bg-white/10" />
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,19 @@
<script setup lang="ts">
import { OnClickOutside } from '@vueuse/components'
import { vibrate } from '@/composables/useHaptics'
</script>
<template>
<OnClickOutside>
<UIDropdown class="search-active-hide">
<template #activator>
<UIButton
dropdown
size="sm"
variant="outline"
class="topbar-button text-white"
@mousedown="vibrate"
>
<i class="fa-fw fa-solid fa-user-gear" />
</UIButton>
</template>
<template #items>
<NotesSourceSwitcher />
<AccountSettings />
<SignOut />
</template>
</UIDropdown>
</OnClickOutside>
<OnClickOutside>
<UIDropdown class="search-active-hide">
<template #activator>
<UIButton :dropdown="true" size="sm" variant="outline" class="topbar-button text-white">
<i class="fa-fw fa-solid fa-user-gear" />
</UIButton>
</template>
<template #items>
<NotesSourceSwitcher />
<AccountSettings />
<SignOut />
</template>
</UIDropdown>
</OnClickOutside>
</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,5 +1,4 @@
<script setup lang="ts">
import { sendEmailVerification } from 'firebase/auth'
import { user } from '@/composables/useFirebase'
import { encryptionKey, enableEncryption, disableEncryption } from '@/composables/useEncryption'
import { notes } from '@/composables/useNotes'
@@ -9,29 +8,31 @@ import FileSaver from 'file-saver'
const verificationEmailSent = ref(false)
const sendVerificationMail = () => {
if (!user.value) throw Error("User doesn't exist, can't send verification email")
sendEmailVerification(user.value)
verificationEmailSent.value = true
if (!user.value) throw Error("User doesn't exist, can't send verification email")
user.value.sendEmailVerification()
verificationEmailSent.value = true
}
console.log(user.value)
const exportNotes = async () => {
const zip = new JSZip()
notes.value.forEach((note) => {
zip.file(`${note.title}-${note.id}.md`, note.content)
})
const blob = await zip.generateAsync({ type: 'blob' })
const currentDate = format(new Date(), 'yyyyMMdd')
FileSaver.saveAs(blob, `contexted-${user.value?.email}-${currentDate}.zip`)
const zip = new JSZip()
notes.value.forEach((note) => {
zip.file(`${note.title}-${note.id}.md`, note.content)
})
const blob = await zip.generateAsync({ type: 'blob' })
const currentDate = format(new Date(), 'yyyyMMdd')
FileSaver.saveAs(blob, `contexted-${user.value?.email}-${currentDate}.zip`)
}
const showDeleteAccountDialog = ref(false)
const deleteAccount = async () => {
await user.value?.delete()
await user.value?.delete()
}
const showEncryptionDialog = ref(false)
watch(showEncryptionDialog, () => {
passphrase.value = ''
passphrase.value = ''
})
const passphrase = ref('')
const toggleEncryptionError = ref('')
@@ -39,205 +40,167 @@ const toggleEncryptionError = ref('')
const encryptionEnabled = computed(() => Boolean(encryptionKey.value))
const toggleEncryption = async () => {
const result = encryptionEnabled.value
? await disableEncryption(passphrase.value)
: await enableEncryption(passphrase.value)
if (typeof result === 'string') {
toggleEncryptionError.value = result
} else {
toggleEncryptionError.value = ''
showEncryptionDialog.value = false
}
const result = encryptionEnabled.value
? await disableEncryption(passphrase.value)
: await enableEncryption(passphrase.value)
if (typeof result === 'string') {
toggleEncryptionError.value = result
} else {
toggleEncryptionError.value = ''
showEncryptionDialog.value = false
}
}
</script>
<template>
<UIModal size="lg">
<template #activator="{ open }">
<UIDropdownItem @click="open">
<i class="fa-fw fa-solid fa-sliders" />
Account settings
</UIDropdownItem>
</template>
<template #title>
<i class="fa-fw fa-solid fa-sliders mr-2" />
Account settings
</template>
<template #default>
<div class="space-y-2">
<UICard>
<template #title>Account</template>
<template #default>
<template v-if="user?.email">
<div class="w-full flex-row sm:flex">
<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>
<UIModal size="lg">
<template #activator="{ open }">
<UIDropdownItem @click="open">
<i class="fa-fw fa-solid fa-sliders" />
Account settings
</UIDropdownItem>
</template>
<template #title>
<i class="fa-fw fa-solid fa-sliders mr-2" />
Account settings
</template>
<template #default>
<div class="space-y-2">
<UICard>
<template #title>Account</template>
<template #default>
<div class="w-full flex-row sm:flex" v-if="user?.email">
<div class="font-bold sm:w-4/12">E-mail address</div>
<div>{{ user?.email }}</div>
</div>
</template>
</UIModal>
<div class="w-full flex-row sm:flex" v-if="user?.email">
<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>
<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>

View File

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

View File

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

View File

@@ -3,108 +3,105 @@ import { getNoteReferences, setActiveNote, findNotes, deleteNote } from '@/compo
import { formatDate } from '@/utils/helpers'
const notesWithReferences = computed(() => {
return findNotes(filter.value).map((note) => ({
...note,
references: getNoteReferences(note)
}))
return findNotes(filter.value).map((note) => ({
...note,
references: getNoteReferences(note)
}))
})
const selectedNotes = ref<{ [key: string]: Boolean }>({})
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) => {
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 deleteSelectedNotes = (closeModal: () => void) => {
closeModal()
const notesToDelete = Object.entries(selectedNotes.value)
.filter(([, selected]) => Boolean(selected))
.map(([id]) => id)
notesToDelete.forEach((noteId) => deleteNote(noteId))
selectedNotes.value = {}
closeModal()
const notesToDelete = Object.entries(selectedNotes.value)
.filter(([, selected]) => Boolean(selected))
.map(([id]) => id)
notesToDelete.forEach((noteId) => deleteNote(noteId))
selectedNotes.value = {}
}
</script>
<template>
<div class="flex w-full flex-col gap-2">
<div class="flex items-center gap-2">
<div class="flex items-center">
<span class="whitespace-nowrap">
{{ notesWithReferences.length }}
{{ notesWithReferences.length === 1 ? 'note' : 'notes' }}
</span>
<template v-if="countSelectedNotes > 0">
<span class="mx-1">|</span>
<div class="whitespace-nowrap font-semibold">
{{ countSelectedNotes }} selected
</div>
</template>
</div>
<UIModal v-if="countSelectedNotes > 0">
<template #activator="{ open }">
<UIButton size="sm" @click="open">Delete</UIButton>
</template>
<template #default>Are you sure you want to delete the selected notes?</template>
<template #actions="{ close }">
<UIButton size="sm" color="primary" @click="deleteSelectedNotes(close)">
Delete selected notes
</UIButton>
<UIButton size="sm" @click="close">Close</UIButton>
</template>
</UIModal>
<UIInputText
size="sm"
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 class="flex w-full flex-col gap-2">
<div class="flex items-center gap-2">
<div class="flex items-center">
<span class="whitespace-nowrap">
{{ notesWithReferences.length }} {{ notesWithReferences.length === 1 ? 'note' : 'notes' }}
</span>
<template v-if="countSelectedNotes > 0">
<span class="mx-1">|</span>
<div class="whitespace-nowrap font-semibold">{{ countSelectedNotes }} selected</div>
</template>
</div>
<UIModal v-if="countSelectedNotes > 0">
<template #activator="{ open }">
<UIButton size="sm" @click="open">Delete</UIButton>
</template>
<template #default>Are you sure you want to delete the selected notes?</template>
<template #actions="{ close }">
<UIButton size="sm" color="primary" @click="deleteSelectedNotes(close)">
Delete selected notes
</UIButton>
<UIButton size="sm" @click="close">Close</UIButton>
</template>
</UIModal>
<UIInputText
size="sm"
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>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
<template>
<div class="dui-dropdown-end dui-dropdown">
<slot name="activator" tabindex="0"></slot>
<ul
tabindex="0"
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>
</ul>
</div>
<div class="dui-dropdown-end dui-dropdown">
<slot name="activator" tabindex="0"></slot>
<ul
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"
>
<slot name="items"></slot>
</ul>
</div>
</template>

View File

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

View File

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

View File

@@ -1,46 +1,46 @@
<script setup lang="ts">
interface Props {
modelValue?: any
size?: 'xs' | 'sm' | 'md' | 'lg'
color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
modelValue?: any
size?: 'xs' | 'sm' | 'md' | 'lg'
color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
color: 'regular'
size: 'md',
color: 'regular'
})
const emit = defineEmits<{
'update:modelValue': [value: any]
'update:modelValue': [value: any]
}>()
const styleClass = computed(() => {
const sizeVariants = {
xs: 'dui-input-xs',
sm: 'dui-input-sm',
md: 'dui-input-md',
lg: 'dui-input-lg'
}
const colorVariants = {
regular: '',
primary: 'dui-input-primary',
secondary: 'dui-input-secondary',
info: 'dui-input-info',
success: 'dui-input-success',
warning: 'dui-input-warning',
error: 'dui-input-error'
}
const sizeClass = sizeVariants[props.size]
const colorClass = colorVariants[props.color]
return [sizeClass, colorClass]
const sizeVariants = {
xs: 'dui-input-xs',
sm: 'dui-input-sm',
md: 'dui-input-md',
lg: 'dui-input-lg'
}
const colorVariants = {
regular: '',
primary: 'dui-input-primary',
secondary: 'dui-input-secondary',
info: 'dui-input-info',
success: 'dui-input-success',
warning: 'dui-input-warning',
error: 'dui-input-error'
}
const sizeClass = sizeVariants[props.size]
const colorClass = colorVariants[props.color]
return [sizeClass, colorClass]
})
</script>
<template>
<input
type="text"
:value="props.modelValue"
@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="styleClass"
/>
<input
type="text"
:value="props.modelValue"
@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="styleClass"
/>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,13 @@
// import { initializeApp } from 'firebase/app'
import { Capacitor } from '@capacitor/core'
// import firebase from 'firebase/compat/app'
// import type { User } from '@firebase/auth-types'
import { initializeApp } from 'firebase/app'
import firebase from 'firebase/compat/app'
import type { User } from '@firebase/auth-types'
import {
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager
} 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 { getAuth, getRedirectResult } from 'firebase/auth'
// import { getAnalytics } from "firebase/analytics";
// TODO: Add SDKs for Firebase products that you want to use
@@ -27,45 +16,62 @@ import type { Firestore } from 'firebase/firestore'
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
}
export const user = ref<User | null>()
export const initialized = computed<boolean>(() => user.value !== undefined)
export const signOut = async () => firebaseSignOut(getAuth())
export const signOut = () => firebase.auth().signOut()
export const db = ref<Firestore>()
const getFirebaseAuth = async (app: FirebaseApp) => {
if (Capacitor.isNativePlatform()) {
return initializeAuth(app, {
persistence: indexedDBLocalPersistence
})
} else {
return getAuth()
}
}
// Initialize Firebase
export const initializeFirebase = async () => {
const app = initializeApp(firebaseConfig)
const auth = await getFirebaseAuth(app)
onAuthStateChanged(auth, (firebaseUser) => {
console.log('auth state changed', firebaseUser)
user.value = firebaseUser
const app = firebase.initializeApp(firebaseConfig)
const auth = getAuth()
try {
const authRedirectResult = await getRedirectResult(auth)
console.log(authRedirectResult)
// 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

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

View File

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

View File

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

View File

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

74
src/types.d.ts vendored
View File

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

View File

@@ -4,20 +4,20 @@ const encryptionPrefix = 'contexted|'
const salt = 'salt'
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 => {
const decryptedMessage = CryptoJS.AES.decrypt(encryptedMessage, key).toString(CryptoJS.enc.Utf8)
if (!decryptedMessage.startsWith(encryptionPrefix))
throw new Error("Message doesn't have valid encryption")
return decryptedMessage.substring(encryptionPrefix.length)
const decryptedMessage = CryptoJS.AES.decrypt(encryptedMessage, key).toString(CryptoJS.enc.Utf8)
if (!decryptedMessage.startsWith(encryptionPrefix))
throw new Error("Message doesn't have valid encryption")
return decryptedMessage.substring(encryptionPrefix.length)
}
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 = () => {
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'
export const defaultNotes: BaseNote[] = [
{
isRoot: true,
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
{
isRoot: true,
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
# How does it work?
* Create a new note by typing words between [[brackets]]
* Click on **Mindmap mode** in the menu left to visualize your notes
\n
Let's get started!`
},
{
title: 'brackets',
content: `If you type square brackets around text a link is created automatically. Like magic!`,
isRoot: false
}
},
{
title: 'brackets',
content: `If you type square brackets around text a link is created automatically. Like magic!`,
isRoot: false
}
].map((note) => ({
id: shortid.generate(),
created: new Date().getTime(),
modified: new Date().getTime(),
...note
id: shortid.generate(),
created: new Date().getTime(),
modified: new Date().getTime(),
...note
}))

View File

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

View File

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

View File

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