Compare commits
32 Commits
redesign-a
...
adding-and
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e3d4a96d | ||
|
|
b1ddcaa849 | ||
|
|
d1a96c5c67 | ||
|
|
6970ea40f3 | ||
|
|
a2eed3c38b | ||
|
|
d099f72398 | ||
|
|
8141e55521 | ||
|
|
c5403313d9 | ||
|
|
ea24c9eed5 | ||
|
|
edeac88a92 | ||
|
|
1785fac843 | ||
|
|
eadc2d6606 | ||
|
|
af37fb7095 | ||
|
|
0e162ddcd5 | ||
|
|
66604f72a5 | ||
|
|
b709c7b5ce | ||
|
|
f37c09a32a | ||
|
|
c0b3728ceb | ||
|
|
528a8ba3a3 | ||
|
|
d7a1a1e4ee | ||
|
|
eefb7fc0ea | ||
|
|
7df3395c3e | ||
|
|
8eb4e3cd5e | ||
|
|
9fde77ca67 | ||
|
|
35e81a8cb9 | ||
|
|
bf88052d53 | ||
|
|
e4c9b3f8b2 | ||
|
|
6235e54d56 | ||
|
|
171c2e40c0 | ||
|
|
4407d0883c | ||
|
|
fd5426385e | ||
|
|
e2e5f7d3b1 |
17
.github/scripts/get_version_code.py
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import json, os
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
|
||||||
|
creds = service_account.Credentials.from_service_account_info(
|
||||||
|
json.loads(os.environ['PLAY_SERVICE_ACCOUNT_JSON']),
|
||||||
|
scopes=['https://www.googleapis.com/auth/androidpublisher']
|
||||||
|
)
|
||||||
|
service = build('androidpublisher', 'v3', credentials=creds)
|
||||||
|
edit = service.edits().insert(packageName='nl.resoftware.pocketgraph', body={}).execute()
|
||||||
|
bundles = service.edits().bundles().list(
|
||||||
|
packageName='nl.resoftware.pocketgraph',
|
||||||
|
editId=edit['id']
|
||||||
|
).execute()
|
||||||
|
service.edits().delete(packageName='nl.resoftware.pocketgraph', editId=edit['id']).execute()
|
||||||
|
max_code = max([b['versionCode'] for b in bundles.get('bundles', [])], default=0)
|
||||||
|
print(max_code + 1)
|
||||||
57
.github/workflows/deploy-landing.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Deploy Landing to Cloudflare Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "landing/**"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "landing/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: landing/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: landing
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: landing
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
id: deploy
|
||||||
|
uses: cloudflare/wrangler-action@v3
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
command: pages deploy dist --project-name=pocketgraph-app --branch=${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main' }}
|
||||||
|
workingDirectory: landing
|
||||||
|
|
||||||
|
- name: Comment preview URL on PR
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: thollander/actions-comment-pull-request@v3
|
||||||
|
with:
|
||||||
|
message: |
|
||||||
|
🔍 **Preview deployment ready!**
|
||||||
|
${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
comment-tag: cloudflare-preview
|
||||||
70
.github/workflows/deploy-playstore.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Deploy to Play Store
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "Xamarin.Neo4j/**"
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
environment: android-prod
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Setup JDK
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: microsoft
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Install MAUI Android workload
|
||||||
|
run: dotnet workload install maui-android
|
||||||
|
|
||||||
|
- name: Decode keystore
|
||||||
|
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > ${{ runner.temp }}/pocketgraph.jks
|
||||||
|
|
||||||
|
- name: Get next version code from Play Store
|
||||||
|
id: version
|
||||||
|
env:
|
||||||
|
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
run: |
|
||||||
|
pip install google-api-python-client google-auth --quiet
|
||||||
|
VERSION_CODE=$(python3 .github/scripts/get_version_code.py)
|
||||||
|
sed -i "s/android:versionCode=\"[^\"]*\"/android:versionCode=\"$VERSION_CODE\"/" \
|
||||||
|
Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Android/Properties/AndroidManifest.xml
|
||||||
|
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
|
||||||
|
echo "Version code: $VERSION_CODE"
|
||||||
|
|
||||||
|
- name: Build signed AAB
|
||||||
|
working-directory: Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Android
|
||||||
|
run: |
|
||||||
|
dotnet publish -f net10.0-android -c Release \
|
||||||
|
-p:AndroidPackageFormat=aab \
|
||||||
|
-p:AndroidKeyStore=true \
|
||||||
|
-p:AndroidSigningKeyStore=${{ runner.temp }}/pocketgraph.jks \
|
||||||
|
-p:AndroidSigningKeyAlias=${{ secrets.KEYSTORE_ALIAS }} \
|
||||||
|
-p:AndroidSigningStorePass=${{ secrets.KEYSTORE_STORE_PASS }} \
|
||||||
|
-p:AndroidSigningKeyPass=${{ secrets.KEYSTORE_KEY_PASS }}
|
||||||
|
|
||||||
|
- name: Upload to Play Store
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
packageName: nl.resoftware.pocketgraph
|
||||||
|
releaseFiles: Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Android/bin/Release/net10.0-android/publish/*-Signed.aab
|
||||||
|
track: internal
|
||||||
|
status: completed
|
||||||
|
whatsNewDirectory: Xamarin.Neo4j/distribution/whatsnew
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# PocketGraph
|
# PocketGraph
|
||||||
|
|
||||||
A mobile Neo4j and Memgraph client for iOS, built with .NET MAUI.
|
A Neo4j and Memgraph client for iPhone and iPad, built with .NET MAUI.
|
||||||
|
|
||||||
|
**[pocketgraph.app](https://pocketgraph.app)**
|
||||||
|
|
||||||
[](https://apps.apple.com/nl/app/pocketgraph/id1604368926)
|
[](https://apps.apple.com/nl/app/pocketgraph/id1604368926)
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Android.Content;
|
using Android.Content;
|
||||||
|
using Android.Content.Res;
|
||||||
using Android.Graphics;
|
using Android.Graphics;
|
||||||
|
using Android.Graphics.Drawables;
|
||||||
using Android.Text;
|
using Android.Text;
|
||||||
using Android.Text.Style;
|
using Android.Text.Style;
|
||||||
using Android.Views;
|
using Android.Views;
|
||||||
using Android.Views.InputMethods;
|
using Android.Views.InputMethods;
|
||||||
using Android.Widget;
|
using Android.Widget;
|
||||||
|
using AndroidX.Core.View;
|
||||||
using Microsoft.Maui.Handlers;
|
using Microsoft.Maui.Handlers;
|
||||||
using Microsoft.Maui.Platform;
|
using Microsoft.Maui.Platform;
|
||||||
using Xamarin.Neo4j.Controls;
|
using Xamarin.Neo4j.Controls;
|
||||||
@@ -49,6 +52,9 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
|||||||
"FALSE", "NULL", "TRUE"
|
"FALSE", "NULL", "TRUE"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private LinearLayout _toolbar;
|
||||||
|
private bool _toolbarAttached;
|
||||||
|
|
||||||
protected override void ConnectHandler(MauiAppCompatEditText platformView)
|
protected override void ConnectHandler(MauiAppCompatEditText platformView)
|
||||||
{
|
{
|
||||||
base.ConnectHandler(platformView);
|
base.ConnectHandler(platformView);
|
||||||
@@ -58,43 +64,136 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
|||||||
| InputTypes.TextFlagMultiLine
|
| InputTypes.TextFlagMultiLine
|
||||||
| InputTypes.TextFlagNoSuggestions;
|
| InputTypes.TextFlagNoSuggestions;
|
||||||
|
|
||||||
// Keyboard toolbar with symbol keys and Execute button
|
// Build the keyboard toolbar
|
||||||
var toolbar = BuildToolbar(platformView);
|
_toolbar = BuildToolbar(platformView);
|
||||||
|
|
||||||
|
// Attach toolbar once to the content view (stays in hierarchy, just hidden)
|
||||||
|
AttachToolbarOnce(platformView);
|
||||||
|
|
||||||
|
// Detect keyboard via visible frame height difference.
|
||||||
|
// Edge-to-edge and varying MAUI soft-input modes make WindowInsets unreliable,
|
||||||
|
// so we compare the root view height to the visible display frame.
|
||||||
|
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
|
||||||
|
{
|
||||||
|
var rootView = platformView.RootView;
|
||||||
|
if (rootView == null) return;
|
||||||
|
|
||||||
|
var rect = new global::Android.Graphics.Rect();
|
||||||
|
rootView.GetWindowVisibleDisplayFrame(rect);
|
||||||
|
var screenHeight = rootView.Height;
|
||||||
|
var keyboardHeight = screenHeight - rect.Bottom;
|
||||||
|
|
||||||
|
if (keyboardHeight > screenHeight * 0.15 && platformView.HasFocus)
|
||||||
|
{
|
||||||
|
if (_toolbar.LayoutParameters is FrameLayout.LayoutParams lp)
|
||||||
|
{
|
||||||
|
lp.BottomMargin = keyboardHeight;
|
||||||
|
_toolbar.LayoutParameters = lp;
|
||||||
|
}
|
||||||
|
_toolbar.Visibility = ViewStates.Visible;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toolbar.Visibility = ViewStates.Gone;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also hide on focus loss
|
||||||
platformView.FocusChange += (s, e) =>
|
platformView.FocusChange += (s, e) =>
|
||||||
toolbar.Visibility = e.HasFocus ? ViewStates.Visible : ViewStates.Gone;
|
{
|
||||||
|
if (!e.HasFocus)
|
||||||
|
_toolbar.Visibility = ViewStates.Gone;
|
||||||
|
};
|
||||||
|
|
||||||
// Syntax highlighting
|
// Syntax highlighting
|
||||||
platformView.AddTextChangedListener(new CypherTextWatcher(platformView, _keyWords));
|
platformView.AddTextChangedListener(new CypherTextWatcher(platformView, _keyWords));
|
||||||
HighlightSyntax(platformView, _keyWords);
|
HighlightSyntax(platformView, _keyWords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AttachToolbarOnce(MauiAppCompatEditText platformView)
|
||||||
|
{
|
||||||
|
if (_toolbarAttached) return;
|
||||||
|
|
||||||
|
var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity;
|
||||||
|
var decorView = activity?.Window?.DecorView as ViewGroup;
|
||||||
|
var contentView = decorView?.FindViewById<FrameLayout>(global::Android.Resource.Id.Content);
|
||||||
|
if (contentView == null) return;
|
||||||
|
|
||||||
|
var layoutParams = new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MatchParent,
|
||||||
|
ViewGroup.LayoutParams.WrapContent,
|
||||||
|
GravityFlags.Bottom);
|
||||||
|
contentView.AddView(_toolbar, layoutParams);
|
||||||
|
_toolbar.Visibility = ViewStates.Gone;
|
||||||
|
_toolbarAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
private LinearLayout BuildToolbar(MauiAppCompatEditText platformView)
|
private LinearLayout BuildToolbar(MauiAppCompatEditText platformView)
|
||||||
{
|
{
|
||||||
var context = platformView.Context;
|
var context = platformView.Context;
|
||||||
|
var density = context.Resources.DisplayMetrics.Density;
|
||||||
|
|
||||||
var toolbar = new LinearLayout(context)
|
var toolbar = new LinearLayout(context)
|
||||||
{
|
{
|
||||||
Orientation = Orientation.Horizontal,
|
Orientation = global::Android.Widget.Orientation.Horizontal,
|
||||||
LayoutParameters = new LinearLayout.LayoutParams(
|
LayoutParameters = new LinearLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MatchParent,
|
ViewGroup.LayoutParams.MatchParent,
|
||||||
ViewGroup.LayoutParams.WrapContent)
|
(int)(48 * density))
|
||||||
};
|
};
|
||||||
toolbar.SetBackgroundColor(Color.ParseColor("#F2F2F7"));
|
|
||||||
toolbar.SetPadding(8, 8, 8, 8);
|
|
||||||
toolbar.Visibility = ViewStates.Gone;
|
|
||||||
|
|
||||||
// Scrollable symbol key strip
|
// Theme-aware background
|
||||||
|
var isDark = (context.Resources.Configuration.UiMode & UiMode.NightMask) == UiMode.NightYes;
|
||||||
|
toolbar.SetBackgroundColor(Color.ParseColor(isDark ? "#141414" : "#F2F2F7"));
|
||||||
|
toolbar.SetPadding((int)(6 * density), (int)(6 * density), (int)(6 * density), (int)(6 * density));
|
||||||
|
toolbar.SetGravity(GravityFlags.CenterVertical);
|
||||||
|
|
||||||
|
// Pill container for symbol keys
|
||||||
|
var pillContainer = new LinearLayout(context)
|
||||||
|
{
|
||||||
|
Orientation = global::Android.Widget.Orientation.Horizontal,
|
||||||
|
LayoutParameters = new LinearLayout.LayoutParams(
|
||||||
|
0, (int)(36 * density), 1f)
|
||||||
|
};
|
||||||
|
var pillBg = new GradientDrawable();
|
||||||
|
pillBg.SetCornerRadius(18 * density);
|
||||||
|
pillBg.SetColor(Color.ParseColor(isDark ? "#2a2a2a" : "#E8E8ED").ToArgb());
|
||||||
|
pillContainer.Background = pillBg;
|
||||||
|
pillContainer.SetGravity(GravityFlags.CenterVertical);
|
||||||
|
|
||||||
|
// Scrollable symbol key strip inside pill
|
||||||
var scrollView = new HorizontalScrollView(context)
|
var scrollView = new HorizontalScrollView(context)
|
||||||
{
|
{
|
||||||
LayoutParameters = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)
|
LayoutParameters = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)
|
||||||
};
|
};
|
||||||
scrollView.HorizontalScrollBarEnabled = false;
|
scrollView.HorizontalScrollBarEnabled = false;
|
||||||
|
|
||||||
var keyRow = new LinearLayout(context) { Orientation = Orientation.Horizontal };
|
var keyRow = new LinearLayout(context)
|
||||||
foreach (var key in new[] { "(", ")", "[", "]", ":", "-", "->", "<-" })
|
{
|
||||||
|
Orientation = global::Android.Widget.Orientation.Horizontal,
|
||||||
|
LayoutParameters = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WrapContent, ViewGroup.LayoutParams.MatchParent)
|
||||||
|
};
|
||||||
|
keyRow.SetGravity(GravityFlags.CenterVertical);
|
||||||
|
|
||||||
|
var symbolTextColor = Color.ParseColor(isDark ? "#e2e2e2" : "#0c0c0c");
|
||||||
|
foreach (var key in new[] { "(", ")", "[", "]", "{", "}", ":", "-", "->", "<-", "\"", "'", ".", "=", "*", "$" })
|
||||||
{
|
{
|
||||||
var captured = key;
|
var captured = key;
|
||||||
var btn = CreatePillButton(context, captured);
|
var btn = new TextView(context)
|
||||||
|
{
|
||||||
|
Text = captured,
|
||||||
|
Gravity = GravityFlags.Center,
|
||||||
|
Clickable = true,
|
||||||
|
Focusable = true,
|
||||||
|
LayoutParameters = new LinearLayout.LayoutParams(
|
||||||
|
(int)(40 * density), ViewGroup.LayoutParams.MatchParent)
|
||||||
|
};
|
||||||
|
btn.SetTextColor(symbolTextColor);
|
||||||
|
btn.SetBackgroundColor(Color.Transparent);
|
||||||
|
btn.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 15);
|
||||||
|
btn.SetPadding(0, 0, 0, 0);
|
||||||
|
btn.SetIncludeFontPadding(false);
|
||||||
btn.Click += (s, e) =>
|
btn.Click += (s, e) =>
|
||||||
{
|
{
|
||||||
var start = Math.Max(platformView.SelectionStart, 0);
|
var start = Math.Max(platformView.SelectionStart, 0);
|
||||||
@@ -105,12 +204,34 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
|||||||
}
|
}
|
||||||
|
|
||||||
scrollView.AddView(keyRow);
|
scrollView.AddView(keyRow);
|
||||||
toolbar.AddView(scrollView);
|
pillContainer.AddView(scrollView);
|
||||||
|
toolbar.AddView(pillContainer);
|
||||||
|
|
||||||
// Execute button
|
// Spacer
|
||||||
var executeBtn = CreatePillButton(context, "Execute");
|
var spacer = new global::Android.Views.View(context)
|
||||||
|
{
|
||||||
|
LayoutParameters = new LinearLayout.LayoutParams((int)(6 * density), 0)
|
||||||
|
};
|
||||||
|
toolbar.AddView(spacer);
|
||||||
|
|
||||||
|
// Execute button — blue circle with play icon (▶)
|
||||||
|
var executeBtn = new TextView(context)
|
||||||
|
{
|
||||||
|
Text = "\u25B6",
|
||||||
|
LayoutParameters = new LinearLayout.LayoutParams(
|
||||||
|
(int)(36 * density), (int)(36 * density)),
|
||||||
|
Gravity = GravityFlags.Center,
|
||||||
|
Clickable = true,
|
||||||
|
Focusable = true
|
||||||
|
};
|
||||||
executeBtn.SetTextColor(Color.White);
|
executeBtn.SetTextColor(Color.White);
|
||||||
executeBtn.SetBackgroundColor(Color.ParseColor("#007AFF"));
|
executeBtn.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 18);
|
||||||
|
var executeBg = new GradientDrawable();
|
||||||
|
executeBg.SetCornerRadius(18 * density);
|
||||||
|
executeBg.SetColor(Color.ParseColor("#007AFF").ToArgb());
|
||||||
|
executeBtn.Background = executeBg;
|
||||||
|
executeBtn.SetPadding(0, 0, 0, 0);
|
||||||
|
executeBtn.SetIncludeFontPadding(false);
|
||||||
executeBtn.Click += (s, e) =>
|
executeBtn.Click += (s, e) =>
|
||||||
{
|
{
|
||||||
if (VirtualView is QueryEditor queryEditor)
|
if (VirtualView is QueryEditor queryEditor)
|
||||||
@@ -120,33 +241,23 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
|||||||
imm?.HideSoftInputFromWindow(platformView.WindowToken, 0);
|
imm?.HideSoftInputFromWindow(platformView.WindowToken, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Disable run button when query is empty
|
||||||
|
void updateRunEnabled()
|
||||||
|
{
|
||||||
|
var hasText = !string.IsNullOrWhiteSpace(platformView.Text);
|
||||||
|
executeBtn.Enabled = hasText;
|
||||||
|
executeBtn.Alpha = hasText ? 1f : 0.35f;
|
||||||
|
}
|
||||||
|
updateRunEnabled();
|
||||||
|
platformView.AddTextChangedListener(new SimpleTextWatcher(updateRunEnabled));
|
||||||
|
|
||||||
toolbar.AddView(executeBtn);
|
toolbar.AddView(executeBtn);
|
||||||
|
|
||||||
// Attach toolbar to the parent view hierarchy
|
toolbar.Visibility = ViewStates.Gone;
|
||||||
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
|
|
||||||
{
|
|
||||||
if (platformView.Parent is ViewGroup parent && toolbar.Parent == null)
|
|
||||||
parent.AddView(toolbar);
|
|
||||||
};
|
|
||||||
|
|
||||||
return toolbar;
|
return toolbar;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Button CreatePillButton(Context context, string label)
|
|
||||||
{
|
|
||||||
return new Button(context)
|
|
||||||
{
|
|
||||||
Text = label,
|
|
||||||
LayoutParameters = new LinearLayout.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.WrapContent,
|
|
||||||
ViewGroup.LayoutParams.WrapContent)
|
|
||||||
{
|
|
||||||
MarginStart = 4,
|
|
||||||
MarginEnd = 4
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Syntax highlighting ───────────────────────────────────────────────
|
// ── Syntax highlighting ───────────────────────────────────────────────
|
||||||
|
|
||||||
internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
|
internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
|
||||||
@@ -191,6 +302,15 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
|||||||
|
|
||||||
// ── Inner helpers ─────────────────────────────────────────────────────
|
// ── Inner helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class SimpleTextWatcher : Java.Lang.Object, ITextWatcher
|
||||||
|
{
|
||||||
|
private readonly Action _callback;
|
||||||
|
public SimpleTextWatcher(Action callback) { _callback = callback; }
|
||||||
|
public void BeforeTextChanged(Java.Lang.ICharSequence s, int start, int count, int after) { }
|
||||||
|
public void OnTextChanged(Java.Lang.ICharSequence s, int start, int before, int count) { }
|
||||||
|
public void AfterTextChanged(IEditable s) { _callback(); }
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class CypherTextWatcher : Java.Lang.Object, ITextWatcher
|
private sealed class CypherTextWatcher : Java.Lang.Object, ITextWatcher
|
||||||
{
|
{
|
||||||
private readonly MauiAppCompatEditText _view;
|
private readonly MauiAppCompatEditText _view;
|
||||||
@@ -210,9 +330,50 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
|||||||
{
|
{
|
||||||
if (_updating) return;
|
if (_updating) return;
|
||||||
_updating = true;
|
_updating = true;
|
||||||
try { HighlightSyntax(_view, _keywords); }
|
try
|
||||||
|
{
|
||||||
|
AutoCapitalizeKeywords(_view, _keywords);
|
||||||
|
HighlightSyntax(_view, _keywords);
|
||||||
|
}
|
||||||
finally { _updating = false; }
|
finally { _updating = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static void AutoCapitalizeKeywords(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
|
||||||
|
{
|
||||||
|
if (!Microsoft.Maui.Storage.Preferences.Default.Get("auto_capitalize", true)) return;
|
||||||
|
|
||||||
|
var text = platformView.Text ?? string.Empty;
|
||||||
|
var selStart = platformView.SelectionStart;
|
||||||
|
var selEnd = platformView.SelectionEnd;
|
||||||
|
var changed = false;
|
||||||
|
var chars = text.ToCharArray();
|
||||||
|
|
||||||
|
foreach (var word in keywords)
|
||||||
|
{
|
||||||
|
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
|
||||||
|
foreach (Match match in regex.Matches(text))
|
||||||
|
{
|
||||||
|
// Skip if already uppercase
|
||||||
|
var segment = text.Substring(match.Index, match.Length);
|
||||||
|
if (segment == word) continue;
|
||||||
|
|
||||||
|
// Don't capitalize if cursor is right at the end of this word (user still typing)
|
||||||
|
if (selStart == match.Index + match.Length) continue;
|
||||||
|
|
||||||
|
for (var i = 0; i < match.Length; i++)
|
||||||
|
chars[match.Index + i] = word[i];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
var newText = new string(chars);
|
||||||
|
platformView.SetText(newText, TextView.BufferType.Editable);
|
||||||
|
if (selStart >= 0 && selStart <= newText.Length)
|
||||||
|
platformView.SetSelection(Math.Min(selStart, newText.Length), Math.Min(selEnd, newText.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,21 +53,24 @@ namespace Xamarin.Neo4j.Android
|
|||||||
{
|
{
|
||||||
base.OnWindowFocusChanged(hasFocus);
|
base.OnWindowFocusChanged(hasFocus);
|
||||||
if (hasFocus)
|
if (hasFocus)
|
||||||
TintToolbarOverflow(Window?.DecorView as ViewGroup);
|
TintToolbarIcons(Window?.DecorView as ViewGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MAUI creates its toolbar programmatically so theme-based tinting doesn't reach
|
// MAUI creates its toolbar programmatically so theme-based tinting doesn't reach
|
||||||
// the overflow icon. Walk the view tree and apply the tint directly.
|
// the overflow icon. Walk the view tree and apply the tint directly.
|
||||||
private static void TintToolbarOverflow(ViewGroup? parent)
|
private static void TintToolbarIcons(ViewGroup? parent)
|
||||||
{
|
{
|
||||||
if (parent == null) return;
|
if (parent == null) return;
|
||||||
for (var i = 0; i < parent.ChildCount; i++)
|
for (var i = 0; i < parent.ChildCount; i++)
|
||||||
{
|
{
|
||||||
var child = parent.GetChildAt(i);
|
var child = parent.GetChildAt(i);
|
||||||
if (child is Toolbar toolbar)
|
if (child is Toolbar toolbar)
|
||||||
|
{
|
||||||
toolbar.OverflowIcon?.SetTint(AColor.White);
|
toolbar.OverflowIcon?.SetTint(AColor.White);
|
||||||
|
toolbar.NavigationIcon?.SetTint(AColor.White);
|
||||||
|
}
|
||||||
else if (child is ViewGroup vg)
|
else if (child is ViewGroup vg)
|
||||||
TintToolbarOverflow(vg);
|
TintToolbarIcons(vg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:versionCode="1"
|
android:versionCode="2"
|
||||||
android:versionName="1.0"
|
android:versionName="2.0.0"
|
||||||
package="nl.resoftware.pocketgraph">
|
package="nl.resoftware.pocketgraph">
|
||||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="36" />
|
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="36" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|||||||
|
After Width: | Height: | Size: 63 KiB |
@@ -9,6 +9,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Android.Runtime;
|
||||||
using Java.Security;
|
using Java.Security;
|
||||||
using Java.Security.Cert;
|
using Java.Security.Cert;
|
||||||
using Javax.Net.Ssl;
|
using Javax.Net.Ssl;
|
||||||
@@ -25,8 +26,9 @@ namespace Xamarin.Neo4j
|
|||||||
var factory = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm);
|
var factory = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm);
|
||||||
factory.Init((KeyStore)null);
|
factory.Init((KeyStore)null);
|
||||||
_androidTrustManager = factory.GetTrustManagers()
|
_androidTrustManager = factory.GetTrustManagers()
|
||||||
.OfType<IX509TrustManager>()
|
.OfType<Java.Lang.Object>()
|
||||||
.First();
|
.Select(m => m.JavaCast<IX509TrustManager>())
|
||||||
|
.First(m => m != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool ValidateServerCertificate(Uri uri, X509Certificate2 certificate, X509Chain chain,
|
public override bool ValidateServerCertificate(Uri uri, X509Certificate2 certificate, X509Chain chain,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<Nullable>disable</Nullable>
|
<Nullable>disable</Nullable>
|
||||||
<!-- Embed all assemblies in the APK so plain adb install works without Fast Deployment -->
|
<!-- Embed all assemblies in the APK so plain adb install works without Fast Deployment -->
|
||||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
||||||
|
<ApplicationDisplayVersion>2.0.0</ApplicationDisplayVersion>
|
||||||
|
<ApplicationVersion>2</ApplicationVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -32,6 +34,10 @@
|
|||||||
Link="Assets\logo.png" />
|
Link="Assets\logo.png" />
|
||||||
<AndroidAsset Include="..\Xamarin.Neo4j.iOS\Resources\resoftware.png"
|
<AndroidAsset Include="..\Xamarin.Neo4j.iOS\Resources\resoftware.png"
|
||||||
Link="Assets\resoftware.png" />
|
Link="Assets\resoftware.png" />
|
||||||
|
<MauiImage Include="..\Xamarin.Neo4j.iOS\Resources\resoftware.png"
|
||||||
|
Link="Resources\Images\resoftware.png" />
|
||||||
|
<MauiImage Include="..\Xamarin.Neo4j.iOS\Resources\logo.png"
|
||||||
|
Link="Resources\Images\logo.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using System.Collections.Generic;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using CoreGraphics;
|
using CoreGraphics;
|
||||||
using Foundation;
|
using Foundation;
|
||||||
|
using Microsoft.Maui;
|
||||||
using Microsoft.Maui.Controls;
|
using Microsoft.Maui.Controls;
|
||||||
using Microsoft.Maui.Handlers;
|
using Microsoft.Maui.Handlers;
|
||||||
using Microsoft.Maui.Platform;
|
using Microsoft.Maui.Platform;
|
||||||
@@ -22,6 +23,15 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
|
|||||||
{
|
{
|
||||||
public class QueryEditorHandler : EditorHandler
|
public class QueryEditorHandler : EditorHandler
|
||||||
{
|
{
|
||||||
|
public QueryEditorHandler()
|
||||||
|
{
|
||||||
|
Mapper.AppendToMapping(nameof(Editor.Text), (handler, _) =>
|
||||||
|
{
|
||||||
|
if (handler is QueryEditorHandler qeh && handler.PlatformView is MauiTextView textView)
|
||||||
|
HighlightWords(textView, qeh._keyWords);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private readonly string[] _keyWords =
|
private readonly string[] _keyWords =
|
||||||
[
|
[
|
||||||
// Clauses
|
// Clauses
|
||||||
@@ -60,14 +70,16 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
|
|||||||
|
|
||||||
const float accessoryHeight = 58f;
|
const float accessoryHeight = 58f;
|
||||||
const float buttonWidth = 33f;
|
const float buttonWidth = 33f;
|
||||||
const float executeButtonWidth = 90f;
|
|
||||||
const float padding = 8f;
|
const float padding = 8f;
|
||||||
const float pillHeight = 38f;
|
const float pillHeight = 38f;
|
||||||
|
|
||||||
var keys = new[]
|
var keys = new[]
|
||||||
{
|
{
|
||||||
("(", "("), (")", ")"), ("[", "["), ("]", "]"),
|
("(", "("), (")", ")"), ("[", "["), ("]", "]"),
|
||||||
(":", ":"), ("-", "-"), ("\u2192", "->"), ("\u2190", "<-")
|
("{", "{"), ("}", "}"), (":", ":"), ("-", "-"),
|
||||||
|
("\u2192", "->"), ("\u2190", "<-"), ("\"", "\""),
|
||||||
|
("'", "'"), (".", "."), ("=", "="), ("*", "*"), ("$", "$")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outer accessory — clear so system background shows through
|
// Outer accessory — clear so system background shows through
|
||||||
@@ -112,50 +124,42 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
|
|||||||
scrollView.BottomAnchor.ConstraintEqualTo(pillContainer.BottomAnchor),
|
scrollView.BottomAnchor.ConstraintEqualTo(pillContainer.BottomAnchor),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Execute button — blue pill
|
|
||||||
var executeBtn = new UIButton(UIButtonType.System);
|
|
||||||
executeBtn.TranslatesAutoresizingMaskIntoConstraints = false;
|
|
||||||
executeBtn.SetTitle("Execute", UIControlState.Normal);
|
|
||||||
executeBtn.SetTitleColor(UIColor.White, UIControlState.Normal);
|
|
||||||
executeBtn.BackgroundColor = UIColor.SystemBlue;
|
|
||||||
executeBtn.Layer.CornerRadius = pillHeight / 2f;
|
|
||||||
executeBtn.Layer.MasksToBounds = true;
|
|
||||||
executeBtn.TouchUpInside += (s, e) =>
|
|
||||||
{
|
|
||||||
if (VirtualView is QueryEditor queryEditor)
|
|
||||||
{
|
|
||||||
queryEditor.RaiseExecuteClicked();
|
|
||||||
platformView.ResignFirstResponder();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
accessoryView.AddSubview(pillContainer);
|
accessoryView.AddSubview(pillContainer);
|
||||||
accessoryView.AddSubview(executeBtn);
|
|
||||||
|
|
||||||
NSLayoutConstraint.ActivateConstraints(new[]
|
NSLayoutConstraint.ActivateConstraints(new[]
|
||||||
{
|
{
|
||||||
executeBtn.TrailingAnchor.ConstraintEqualTo(accessoryView.TrailingAnchor, -padding),
|
|
||||||
executeBtn.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
|
|
||||||
executeBtn.HeightAnchor.ConstraintEqualTo(pillHeight),
|
|
||||||
executeBtn.WidthAnchor.ConstraintEqualTo(executeButtonWidth),
|
|
||||||
|
|
||||||
pillContainer.LeadingAnchor.ConstraintEqualTo(accessoryView.LeadingAnchor, padding),
|
pillContainer.LeadingAnchor.ConstraintEqualTo(accessoryView.LeadingAnchor, padding),
|
||||||
pillContainer.TrailingAnchor.ConstraintEqualTo(executeBtn.LeadingAnchor, -padding),
|
pillContainer.TrailingAnchor.ConstraintEqualTo(accessoryView.TrailingAnchor, -padding),
|
||||||
pillContainer.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
|
pillContainer.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
|
||||||
pillContainer.HeightAnchor.ConstraintEqualTo(pillHeight),
|
pillContainer.HeightAnchor.ConstraintEqualTo(pillHeight),
|
||||||
});
|
});
|
||||||
|
|
||||||
platformView.InputAccessoryView = accessoryView;
|
platformView.InputAccessoryView = accessoryView;
|
||||||
|
|
||||||
platformView.Changed += (s, e) => HighlightWords(platformView, _keyWords);
|
// Ensure accessory hides when keyboard is dismissed by external gesture/scroll
|
||||||
|
var keyboardHideObserver = UIKeyboard.Notifications.ObserveWillHide((s, args) =>
|
||||||
|
{
|
||||||
|
// The InputAccessoryView is removed with the keyboard automatically.
|
||||||
|
// But if the editor remains first responder after an external dismiss
|
||||||
|
// (e.g. interactive dismiss on scroll), resign to fully hide the bar.
|
||||||
|
if (platformView.IsFirstResponder)
|
||||||
|
platformView.ResignFirstResponder();
|
||||||
|
});
|
||||||
|
|
||||||
|
platformView.Changed += (s, e) =>
|
||||||
|
{
|
||||||
|
AutoCapitalizeKeywords(platformView, _keyWords);
|
||||||
HighlightWords(platformView, _keyWords);
|
HighlightWords(platformView, _keyWords);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void HighlightWords(UITextView platformView, IEnumerable<string> wordsToHighlight)
|
private static void HighlightWords(UITextView platformView, IEnumerable<string> wordsToHighlight)
|
||||||
{
|
{
|
||||||
var text = PreprocessText(platformView.Text ?? string.Empty);
|
var text = PreprocessText(platformView.Text ?? string.Empty);
|
||||||
var attributedText = new NSMutableAttributedString(text);
|
var attributedText = new NSMutableAttributedString(text);
|
||||||
|
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor,
|
||||||
|
UIColor.Label,
|
||||||
|
new NSRange(0, text.Length));
|
||||||
|
|
||||||
foreach (var word in wordsToHighlight)
|
foreach (var word in wordsToHighlight)
|
||||||
{
|
{
|
||||||
@@ -192,6 +196,37 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AutoCapitalizeKeywords(UITextView platformView, IEnumerable<string> keywords)
|
||||||
|
{
|
||||||
|
if (!Microsoft.Maui.Storage.Preferences.Default.Get("auto_capitalize", true)) return;
|
||||||
|
|
||||||
|
var text = platformView.Text ?? string.Empty;
|
||||||
|
var cursorPos = platformView.SelectedRange;
|
||||||
|
var changed = false;
|
||||||
|
var chars = text.ToCharArray();
|
||||||
|
|
||||||
|
foreach (var word in keywords)
|
||||||
|
{
|
||||||
|
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
|
||||||
|
foreach (Match match in regex.Matches(text))
|
||||||
|
{
|
||||||
|
var segment = text.Substring(match.Index, match.Length);
|
||||||
|
if (segment == word) continue;
|
||||||
|
if ((nint)cursorPos.Location == match.Index + match.Length) continue;
|
||||||
|
|
||||||
|
for (var i = 0; i < match.Length; i++)
|
||||||
|
chars[match.Index + i] = word[i];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
platformView.Text = new string(chars);
|
||||||
|
platformView.SelectedRange = cursorPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string PreprocessText(string text)
|
private static string PreprocessText(string text)
|
||||||
{
|
{
|
||||||
text = text.Replace("\u2018", "'");
|
text = text.Replace("\u2018", "'");
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="1038"/>
|
<viewLayoutGuide key="safeArea" id="1038"/>
|
||||||
<color key="backgroundColor" red="0.19136750699999999" green="0.2006954849" blue="0.2327556908" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="0.047058823529" green="0.047058823529" blue="0.047058823529" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="198" firstAttribute="centerX" secondItem="1038" secondAttribute="centerX" id="201"/>
|
<constraint firstItem="198" firstAttribute="centerX" secondItem="1038" secondAttribute="centerX" id="201"/>
|
||||||
<constraint firstItem="1038" firstAttribute="centerY" secondItem="198" secondAttribute="centerY" id="203"/>
|
<constraint firstItem="1038" firstAttribute="centerY" secondItem="198" secondAttribute="centerY" id="203"/>
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ namespace Xamarin.Neo4j
|
|||||||
{
|
{
|
||||||
if (MainPage is NavigationPage navPage)
|
if (MainPage is NavigationPage navPage)
|
||||||
{
|
{
|
||||||
|
if (Resources.TryGetValue("NavigationBarColor", out var navColor) && navColor is Color color)
|
||||||
|
navPage.BarBackgroundColor = color;
|
||||||
|
else
|
||||||
navPage.BarBackgroundColor = Color.FromArgb("#31333b");
|
navPage.BarBackgroundColor = Color.FromArgb("#31333b");
|
||||||
|
|
||||||
navPage.BarTextColor = Colors.White;
|
navPage.BarTextColor = Colors.White;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Reflection;
|
|||||||
using Microsoft.Maui.ApplicationModel;
|
using Microsoft.Maui.ApplicationModel;
|
||||||
using Microsoft.Maui.Controls;
|
using Microsoft.Maui.Controls;
|
||||||
using Microsoft.Maui.Controls.Xaml;
|
using Microsoft.Maui.Controls.Xaml;
|
||||||
|
using Microsoft.Maui.Storage;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Maui;
|
using Microsoft.Maui;
|
||||||
using Xamarin.Neo4j.Managers;
|
using Xamarin.Neo4j.Managers;
|
||||||
@@ -42,8 +43,28 @@ namespace Xamarin.Neo4j.Controls
|
|||||||
graphView.Source = new HtmlWebViewSource { Html = QueryResult?.NeovisHtml };
|
graphView.Source = new HtmlWebViewSource { Html = QueryResult?.NeovisHtml };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private double _originalHeight;
|
||||||
|
|
||||||
private async void OnGraphViewNavigating(object sender, WebNavigatingEventArgs e)
|
private async void OnGraphViewNavigating(object sender, WebNavigatingEventArgs e)
|
||||||
{
|
{
|
||||||
|
// Panel open/close — expand WebView so panel has room
|
||||||
|
if (e.Url.Contains("panelopen"))
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
_originalHeight = graphView.HeightRequest;
|
||||||
|
var screenService = IPlatformApplication.Current.Services.GetRequiredService<Services.Interfaces.IScreenSizeService>();
|
||||||
|
var screenHeight = screenService.GetScreenHeight();
|
||||||
|
graphView.HeightRequest = Math.Max(_originalHeight, screenHeight * 0.7);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.Url.Contains("panelclose"))
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
if (_originalHeight > 0)
|
||||||
|
graphView.HeightRequest = _originalHeight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!e.Url.Contains("expand") || !e.Url.Contains("nodeId")) return;
|
if (!e.Url.Contains("expand") || !e.Url.Contains("nodeId")) return;
|
||||||
|
|
||||||
e.Cancel = true;
|
e.Cancel = true;
|
||||||
@@ -110,6 +131,13 @@ namespace Xamarin.Neo4j.Controls
|
|||||||
html = html.Replace("{{edges}}", edgesJson);
|
html = html.Replace("{{edges}}", edgesJson);
|
||||||
html = html.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF");
|
html = html.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF");
|
||||||
html = html.Replace("{{textColor}}", isDark ? "#FFFFFF" : "#000000");
|
html = html.Replace("{{textColor}}", isDark ? "#FFFFFF" : "#000000");
|
||||||
|
html = html.Replace("{{panelBg}}", isDark ? "#141414" : "#ffffff");
|
||||||
|
html = html.Replace("{{borderColor}}", isDark ? "#2a2a2a" : "#e0e0e0");
|
||||||
|
html = html.Replace("{{mutedColor}}", isDark ? "#868686" : "#868a92");
|
||||||
|
html = html.Replace("{{accentColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
|
||||||
|
html = html.Replace("{{primaryColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
|
||||||
|
html = html.Replace("{{errorColor}}", isDark ? "#ff6b6b" : "#c62828");
|
||||||
|
html = html.Replace("{{autoZoom}}", Preferences.Default.Get("auto_zoom", true) ? "true" : "false");
|
||||||
|
|
||||||
QueryResult.NeovisHtml = html;
|
QueryResult.NeovisHtml = html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ namespace Xamarin.Neo4j.Managers
|
|||||||
if (connectionStringToUpdate == null)
|
if (connectionStringToUpdate == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
connectionStringToUpdate.Name = connectionString.Name;
|
||||||
connectionStringToUpdate.Scheme = connectionString.Scheme;
|
connectionStringToUpdate.Scheme = connectionString.Scheme;
|
||||||
connectionStringToUpdate.Host = connectionString.Host;
|
connectionStringToUpdate.Host = connectionString.Host;
|
||||||
connectionStringToUpdate.Username = connectionString.Username;
|
connectionStringToUpdate.Username = connectionString.Username;
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ namespace Xamarin.Neo4j.Models
|
|||||||
|
|
||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
public bool IsSecure => Scheme?.Contains("+s") ?? false;
|
||||||
|
|
||||||
|
public string SchemeShortName => Scheme != null && Scheme.StartsWith("bolt") ? "bolt" : "neo4j";
|
||||||
|
|
||||||
|
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Host : Name;
|
||||||
|
|
||||||
public Tuple<string, bool, bool> ParseHost()
|
public Tuple<string, bool, bool> ParseHost()
|
||||||
{
|
{
|
||||||
var fullHost = Scheme + Host;
|
var fullHost = Scheme + Host;
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
<ScrollView>
|
<ScrollView>
|
||||||
<StackLayout Padding="24" Spacing="12">
|
<StackLayout Padding="24" Spacing="12">
|
||||||
<StackLayout Spacing="4">
|
<StackLayout Spacing="4">
|
||||||
<Label Text="Scheme:" FontAttributes="Bold" FontSize="Small" />
|
<Label Text="Name" FontAttributes="Bold" FontSize="Small" />
|
||||||
|
<Entry Text="{Binding Name}" Placeholder="My Database" />
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
|
<StackLayout Spacing="4">
|
||||||
|
<Label Text="Scheme" FontAttributes="Bold" FontSize="Small" />
|
||||||
<Picker SelectedItem="{Binding Scheme}">
|
<Picker SelectedItem="{Binding Scheme}">
|
||||||
<Picker.ItemsSource>
|
<Picker.ItemsSource>
|
||||||
<x:Array Type="{x:Type x:String}">
|
<x:Array Type="{x:Type x:String}">
|
||||||
@@ -25,17 +30,17 @@
|
|||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
<StackLayout Spacing="4">
|
<StackLayout Spacing="4">
|
||||||
<Label Text="Host:" FontAttributes="Bold" FontSize="Small" />
|
<Label Text="Host" FontAttributes="Bold" FontSize="Small" />
|
||||||
<Entry Text="{Binding Host}" />
|
<Entry Text="{Binding Host}" />
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
<StackLayout Spacing="4">
|
<StackLayout Spacing="4">
|
||||||
<Label Text="Username:" FontAttributes="Bold" FontSize="Small" />
|
<Label Text="Username" FontAttributes="Bold" FontSize="Small" />
|
||||||
<Entry Text="{Binding Username}" Keyboard="Default" />
|
<Entry Text="{Binding Username}" Keyboard="Default" />
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
<StackLayout Spacing="4">
|
<StackLayout Spacing="4">
|
||||||
<Label Text="Password:" FontAttributes="Bold" FontSize="Small" />
|
<Label Text="Password" FontAttributes="Bold" FontSize="Small" />
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<Entry Grid.Column="0" x:Name="passwordEntry"
|
<Entry Grid.Column="0" x:Name="passwordEntry"
|
||||||
Text="{Binding Password}"
|
Text="{Binding Password}"
|
||||||
@@ -51,7 +56,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
<Button Text="Test" BackgroundColor="Transparent" TextColor="{DynamicResource SecondaryTextColor}" BorderColor="{DynamicResource SecondaryTextColor}" BorderWidth="1" Command="{Binding Commands[Test]}" />
|
<Button Text="Test" BackgroundColor="Transparent" TextColor="{DynamicResource PrimaryTextColor}" BorderColor="{DynamicResource PrimaryTextColor}" BorderWidth="1" Command="{Binding Commands[Test]}" />
|
||||||
<Button Text="Connect" BackgroundColor="Transparent" TextColor="{DynamicResource PrimaryTextColor}" BorderColor="{DynamicResource PrimaryTextColor}" BorderWidth="1" Command="{Binding Commands[Connect]}" />
|
<Button Text="Connect" BackgroundColor="Transparent" TextColor="{DynamicResource PrimaryTextColor}" BorderColor="{DynamicResource PrimaryTextColor}" BorderWidth="1" Command="{Binding Commands[Connect]}" />
|
||||||
<Button Text="Save" BackgroundColor="{DynamicResource Accent}" TextColor="{DynamicResource QueryTextBackground}" Command="{Binding Commands[Save]}" />
|
<Button Text="Save" BackgroundColor="{DynamicResource Accent}" TextColor="{DynamicResource QueryTextBackground}" Command="{Binding Commands[Save]}" />
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|||||||
@@ -3,10 +3,23 @@
|
|||||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||||
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
|
x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
|
||||||
x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
|
|
||||||
x:Name="connectionsPage"
|
x:Name="connectionsPage"
|
||||||
Title="PocketGraph">
|
Title=" ">
|
||||||
|
|
||||||
|
<NavigationPage.TitleView>
|
||||||
|
<StackLayout Orientation="Horizontal" Spacing="8" VerticalOptions="Center" HorizontalOptions="Start" Padding="4,0,0,0">
|
||||||
|
<Label Text="{x:Static fonts:FontAwesomeSolid.ProjectDiagram}"
|
||||||
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
|
FontSize="18"
|
||||||
|
TextColor="White"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
<Label Text="PocketGraph"
|
||||||
|
FontSize="18" FontAttributes="Bold"
|
||||||
|
TextColor="White"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
</StackLayout>
|
||||||
|
</NavigationPage.TitleView>
|
||||||
|
|
||||||
<ContentPage.ToolbarItems>
|
<ContentPage.ToolbarItems>
|
||||||
<ToolbarItem Text="Settings" Order="Secondary" Command="{Binding Commands[OpenSettings]}" />
|
<ToolbarItem Text="Settings" Order="Secondary" Command="{Binding Commands[OpenSettings]}" />
|
||||||
@@ -14,19 +27,98 @@ x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
|
|||||||
|
|
||||||
<ContentPage.Content>
|
<ContentPage.Content>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ListView ItemsSource="{Binding ConnectionStrings}" HasUnevenRows="True" SelectionMode="None" ItemTapped="OpenSession" IsVisible="{Binding HasItems}">
|
|
||||||
<ListView.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<controls:ConnectionCell>
|
|
||||||
<controls:ConnectionCell.ContextActions>
|
|
||||||
<MenuItem Text="Edit" Command="{Binding BindingContext.Commands[EditConnectionString], Source={x:Reference connectionsPage}}" CommandParameter="{Binding .}" />
|
|
||||||
<MenuItem Text="Delete" Command="{Binding BindingContext.Commands[DeleteConnectionString], Source={x:Reference connectionsPage}}" CommandParameter="{Binding .}" IsDestructive="true" />
|
|
||||||
</controls:ConnectionCell.ContextActions>
|
|
||||||
</controls:ConnectionCell>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListView.ItemTemplate>
|
|
||||||
</ListView>
|
|
||||||
|
|
||||||
|
<!-- Scrollable connection cards -->
|
||||||
|
<ScrollView IsVisible="{Binding HasItems}">
|
||||||
|
<StackLayout BindableLayout.ItemsSource="{Binding ConnectionStrings}"
|
||||||
|
Spacing="0" Padding="0,16,0,80">
|
||||||
|
<BindableLayout.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Margin="16,0,16,8"
|
||||||
|
BackgroundColor="{DynamicResource Extreme}"
|
||||||
|
Stroke="{DynamicResource QueryTextBorder}"
|
||||||
|
StrokeThickness="1"
|
||||||
|
StrokeShape="RoundRectangle 10">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" Padding="14,12">
|
||||||
|
<Grid.GestureRecognizers>
|
||||||
|
<TapGestureRecognizer
|
||||||
|
Command="{Binding BindingContext.Commands[OpenConnection], Source={x:Reference connectionsPage}}"
|
||||||
|
CommandParameter="{Binding .}" />
|
||||||
|
</Grid.GestureRecognizers>
|
||||||
|
|
||||||
|
<!-- Database icon -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
WidthRequest="40" HeightRequest="40"
|
||||||
|
StrokeThickness="0"
|
||||||
|
StrokeShape="RoundRectangle 20"
|
||||||
|
BackgroundColor="{DynamicResource QueryActionBar}"
|
||||||
|
VerticalOptions="Center">
|
||||||
|
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
|
||||||
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
|
FontSize="16"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
HorizontalOptions="Center"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Name + Host + scheme badge -->
|
||||||
|
<StackLayout Grid.Column="1" Spacing="2" Padding="12,0,8,0" VerticalOptions="Center">
|
||||||
|
<Label Text="{Binding DisplayName}"
|
||||||
|
FontSize="15" FontAttributes="Bold"
|
||||||
|
TextColor="{DynamicResource PrimaryTextColor}"
|
||||||
|
LineBreakMode="TailTruncation" MaxLines="1" />
|
||||||
|
<Label Text="{Binding Host}"
|
||||||
|
FontSize="12"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
LineBreakMode="TailTruncation" MaxLines="1" />
|
||||||
|
<StackLayout Orientation="Horizontal" Spacing="4">
|
||||||
|
<Label IsVisible="{Binding IsSecure}"
|
||||||
|
Text="{x:Static fonts:FontAwesomeSolid.Lock}"
|
||||||
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
|
FontSize="9"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
<Border BackgroundColor="{DynamicResource QueryActionBar}"
|
||||||
|
StrokeThickness="0"
|
||||||
|
StrokeShape="RoundRectangle 6"
|
||||||
|
Padding="6,2">
|
||||||
|
<Label Text="{Binding SchemeShortName}"
|
||||||
|
FontSize="9" FontAttributes="Bold"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
</Border>
|
||||||
|
</StackLayout>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
|
<!-- Edit / Delete -->
|
||||||
|
<StackLayout Grid.Column="2" Orientation="Horizontal" Spacing="0" VerticalOptions="Center">
|
||||||
|
<Button Text="{x:Static fonts:FontAwesomeSolid.PencilAlt}"
|
||||||
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
|
FontSize="14"
|
||||||
|
WidthRequest="38" HeightRequest="38"
|
||||||
|
BackgroundColor="Transparent"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
Padding="0"
|
||||||
|
Command="{Binding BindingContext.Commands[EditConnectionString], Source={x:Reference connectionsPage}}"
|
||||||
|
CommandParameter="{Binding .}" />
|
||||||
|
<Button Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
|
||||||
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
|
FontSize="14"
|
||||||
|
WidthRequest="38" HeightRequest="38"
|
||||||
|
BackgroundColor="Transparent"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
Padding="0"
|
||||||
|
Command="{Binding BindingContext.Commands[DeleteConnectionString], Source={x:Reference connectionsPage}}"
|
||||||
|
CommandParameter="{Binding .}" />
|
||||||
|
</StackLayout>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</BindableLayout.ItemTemplate>
|
||||||
|
</StackLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
<StackLayout IsVisible="{Binding IsEmpty}" VerticalOptions="Center" HorizontalOptions="Center" Spacing="12">
|
<StackLayout IsVisible="{Binding IsEmpty}" VerticalOptions="Center" HorizontalOptions="Center" Spacing="12">
|
||||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Server}"
|
<Label Text="{x:Static fonts:FontAwesomeSolid.Server}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
@@ -50,8 +142,8 @@ x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
|
|||||||
FontSize="20"
|
FontSize="20"
|
||||||
WidthRequest="56" HeightRequest="56"
|
WidthRequest="56" HeightRequest="56"
|
||||||
CornerRadius="28"
|
CornerRadius="28"
|
||||||
BackgroundColor="{DynamicResource Accent}"
|
BackgroundColor="{DynamicResource FabBackgroundColor}"
|
||||||
TextColor="White"
|
TextColor="{DynamicResource FabTextColor}"
|
||||||
VerticalOptions="End" HorizontalOptions="End"
|
VerticalOptions="End" HorizontalOptions="End"
|
||||||
Margin="0,0,16,16"
|
Margin="0,0,16,16"
|
||||||
Command="{Binding Commands[AddConnection]}" />
|
Command="{Binding Commands[AddConnection]}" />
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ namespace Xamarin.Neo4j.Pages
|
|||||||
ViewModel.LoadConnectionStrings();
|
ViewModel.LoadConnectionStrings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenSession(object sender, ItemTappedEventArgs e)
|
|
||||||
{
|
|
||||||
ViewModel.OpenSession((Neo4jConnectionString)e.Item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
<ToolbarItem.IconImageSource>
|
<ToolbarItem.IconImageSource>
|
||||||
<FontImageSource
|
<FontImageSource
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20" />
|
Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20"
|
||||||
|
Color="#007AFF" />
|
||||||
</ToolbarItem.IconImageSource>
|
</ToolbarItem.IconImageSource>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
</ContentPage.ToolbarItems>
|
</ContentPage.ToolbarItems>
|
||||||
@@ -144,64 +145,64 @@
|
|||||||
<StackLayout BindableLayout.ItemsSource="{Binding QueryResults}" Spacing="0">
|
<StackLayout BindableLayout.ItemsSource="{Binding QueryResults}" Spacing="0">
|
||||||
<BindableLayout.ItemTemplate>
|
<BindableLayout.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Border Margin="12,0,12,8"
|
<Border Margin="12,0,12,6"
|
||||||
BackgroundColor="{DynamicResource Extreme}"
|
BackgroundColor="{DynamicResource Extreme}"
|
||||||
Stroke="{DynamicResource QueryTextBorder}"
|
Stroke="{DynamicResource QueryTextBorder}"
|
||||||
StrokeThickness="1"
|
StrokeThickness="1"
|
||||||
StrokeShape="RoundRectangle 4">
|
StrokeShape="RoundRectangle 4">
|
||||||
<StackLayout Spacing="0">
|
<StackLayout Spacing="0">
|
||||||
<!-- Action row -->
|
<!-- Action row -->
|
||||||
<Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,2">
|
<Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,0">
|
||||||
<Button Grid.Column="0"
|
<Button Grid.Column="0"
|
||||||
Text="{x:Static fonts:FontAwesomeSolid.Star}"
|
Text="{x:Static fonts:FontAwesomeSolid.Star}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||||
BackgroundColor="Transparent"
|
BackgroundColor="Transparent"
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
HeightRequest="40"
|
HeightRequest="32"
|
||||||
Command="{Binding BindingContext.Commands[SaveQuery], Source={x:Reference sessionPage}}"
|
Command="{Binding BindingContext.Commands[SaveQuery], Source={x:Reference sessionPage}}"
|
||||||
CommandParameter="{Binding .}" />
|
CommandParameter="{Binding .}" />
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
Text="{x:Static fonts:FontAwesomeSolid.Expand}"
|
Text="{x:Static fonts:FontAwesomeSolid.Expand}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||||
BackgroundColor="Transparent"
|
BackgroundColor="Transparent"
|
||||||
IsEnabled="{Binding CanDisplayGraph}"
|
IsEnabled="{Binding CanDisplayGraph}"
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
HeightRequest="40"
|
HeightRequest="32"
|
||||||
Command="{Binding BindingContext.Commands[OpenGraph], Source={x:Reference sessionPage}}"
|
Command="{Binding BindingContext.Commands[OpenGraph], Source={x:Reference sessionPage}}"
|
||||||
CommandParameter="{Binding .}" />
|
CommandParameter="{Binding .}" />
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
Text="{x:Static fonts:FontAwesomeSolid.Code}"
|
Text="{x:Static fonts:FontAwesomeSolid.Code}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||||
BackgroundColor="Transparent"
|
BackgroundColor="Transparent"
|
||||||
IsEnabled="{Binding Success}"
|
IsEnabled="{Binding Success}"
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
HeightRequest="40"
|
HeightRequest="32"
|
||||||
Command="{Binding BindingContext.Commands[OpenTable], Source={x:Reference sessionPage}}"
|
Command="{Binding BindingContext.Commands[OpenTable], Source={x:Reference sessionPage}}"
|
||||||
CommandParameter="{Binding .}" />
|
CommandParameter="{Binding .}" />
|
||||||
<Button Grid.Column="3"
|
<Button Grid.Column="3"
|
||||||
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
|
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||||
BackgroundColor="Transparent"
|
BackgroundColor="Transparent"
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
HeightRequest="40"
|
HeightRequest="32"
|
||||||
Command="{Binding BindingContext.Commands[DeleteResult], Source={x:Reference sessionPage}}"
|
Command="{Binding BindingContext.Commands[DeleteResult], Source={x:Reference sessionPage}}"
|
||||||
CommandParameter="{Binding .}" />
|
CommandParameter="{Binding .}" />
|
||||||
<Button Grid.Column="4"
|
<Button Grid.Column="4"
|
||||||
Text="{x:Static fonts:FontAwesomeSolid.ChevronUp}"
|
Text="{x:Static fonts:FontAwesomeSolid.ChevronUp}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="12"
|
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="10"
|
||||||
BackgroundColor="Transparent"
|
BackgroundColor="Transparent"
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
WidthRequest="40" HeightRequest="40"
|
WidthRequest="32" HeightRequest="32"
|
||||||
Clicked="ToggleResultCollapse" />
|
Clicked="ToggleResultCollapse" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Query label -->
|
<!-- Query label -->
|
||||||
<Label Text="{Binding DisplayQuery}"
|
<Label Text="{Binding DisplayQuery}"
|
||||||
Padding="12,0,12,8"
|
Padding="12,0,12,4"
|
||||||
FontSize="12"
|
FontSize="11"
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
LineBreakMode="TailTruncation"
|
LineBreakMode="TailTruncation"
|
||||||
MaxLines="2">
|
MaxLines="1">
|
||||||
<Label.GestureRecognizers>
|
<Label.GestureRecognizers>
|
||||||
<TapGestureRecognizer
|
<TapGestureRecognizer
|
||||||
Command="{Binding BindingContext.Commands[LoadResultQuery], Source={x:Reference sessionPage}}"
|
Command="{Binding BindingContext.Commands[LoadResultQuery], Source={x:Reference sessionPage}}"
|
||||||
@@ -219,8 +220,12 @@
|
|||||||
</StackLayout>
|
</StackLayout>
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
<!-- Empty hint (no results yet) -->
|
|
||||||
<StackLayout IsVisible="{Binding IsEmpty}" HorizontalOptions="Center" Spacing="12" Padding="32,24">
|
</StackLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<!-- Empty hint (no results yet) — overlaid and vertically centered -->
|
||||||
|
<StackLayout Grid.Row="1" IsVisible="{Binding IsEmpty}" HorizontalOptions="Center" VerticalOptions="Center" Spacing="12" Padding="32,24">
|
||||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
|
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
FontSize="48"
|
FontSize="48"
|
||||||
@@ -237,9 +242,6 @@
|
|||||||
HorizontalOptions="Center" />
|
HorizontalOptions="Center" />
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
</StackLayout>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage.Content>
|
</ContentPage.Content>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|||||||
@@ -13,14 +13,12 @@
|
|||||||
</ContentPage.IconImageSource>
|
</ContentPage.IconImageSource>
|
||||||
|
|
||||||
<ContentPage.Content>
|
<ContentPage.Content>
|
||||||
<Grid RowDefinitions="*,Auto">
|
<ScrollView>
|
||||||
|
|
||||||
<ScrollView Grid.Row="0">
|
|
||||||
<StackLayout Padding="16" Spacing="24">
|
<StackLayout Padding="16" Spacing="24">
|
||||||
|
|
||||||
<!-- Support -->
|
<!-- Editor -->
|
||||||
<StackLayout Spacing="8">
|
<StackLayout Spacing="8">
|
||||||
<Label Text="SUPPORT"
|
<Label Text="EDITOR"
|
||||||
FontSize="11" FontAttributes="Bold"
|
FontSize="11" FontAttributes="Bold"
|
||||||
CharacterSpacing="2"
|
CharacterSpacing="2"
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
@@ -30,33 +28,21 @@
|
|||||||
StrokeThickness="1"
|
StrokeThickness="1"
|
||||||
StrokeShape="RoundRectangle 4">
|
StrokeShape="RoundRectangle 4">
|
||||||
<StackLayout Spacing="0">
|
<StackLayout Spacing="0">
|
||||||
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
|
<Grid ColumnDefinitions="*,Auto" Padding="16,10">
|
||||||
<Grid.GestureRecognizers>
|
<Label Grid.Column="0" Text="Auto-capitalize keywords"
|
||||||
<TapGestureRecognizer Command="{Binding Commands[ContactSupport]}" />
|
|
||||||
</Grid.GestureRecognizers>
|
|
||||||
<Label Grid.Column="0" Text="Contact Support"
|
|
||||||
TextColor="{DynamicResource PrimaryTextColor}"
|
TextColor="{DynamicResource PrimaryTextColor}"
|
||||||
VerticalOptions="Center" />
|
VerticalOptions="Center" />
|
||||||
<Label Grid.Column="1"
|
<Switch Grid.Column="1" OnColor="#007AFF"
|
||||||
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
|
IsToggled="{Binding AutoCapitalize}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
|
||||||
FontSize="12"
|
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
|
||||||
VerticalOptions="Center" />
|
VerticalOptions="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
|
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
|
||||||
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
|
<Grid ColumnDefinitions="*,Auto" Padding="16,10">
|
||||||
<Grid.GestureRecognizers>
|
<Label Grid.Column="0" Text="Auto-zoom on node tap"
|
||||||
<TapGestureRecognizer Command="{Binding Commands[RateApp]}" />
|
|
||||||
</Grid.GestureRecognizers>
|
|
||||||
<Label Grid.Column="0" Text="Rate PocketGraph"
|
|
||||||
TextColor="{DynamicResource PrimaryTextColor}"
|
TextColor="{DynamicResource PrimaryTextColor}"
|
||||||
VerticalOptions="Center" />
|
VerticalOptions="Center" />
|
||||||
<Label Grid.Column="1"
|
<Switch Grid.Column="1" OnColor="#007AFF"
|
||||||
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
|
IsToggled="{Binding AutoZoom}"
|
||||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
|
||||||
FontSize="12"
|
|
||||||
TextColor="{DynamicResource SecondaryTextColor}"
|
|
||||||
VerticalOptions="Center" />
|
VerticalOptions="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
@@ -108,6 +94,51 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
|
<!-- Support -->
|
||||||
|
<StackLayout Spacing="8">
|
||||||
|
<Label Text="SUPPORT"
|
||||||
|
FontSize="11" FontAttributes="Bold"
|
||||||
|
CharacterSpacing="2"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
Margin="4,0,0,0" />
|
||||||
|
<Border BackgroundColor="{DynamicResource Extreme}"
|
||||||
|
Stroke="{DynamicResource QueryTextBorder}"
|
||||||
|
StrokeThickness="1"
|
||||||
|
StrokeShape="RoundRectangle 4">
|
||||||
|
<StackLayout Spacing="0">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
|
||||||
|
<Grid.GestureRecognizers>
|
||||||
|
<TapGestureRecognizer Command="{Binding Commands[ContactSupport]}" />
|
||||||
|
</Grid.GestureRecognizers>
|
||||||
|
<Label Grid.Column="0" Text="Contact Support"
|
||||||
|
TextColor="{DynamicResource PrimaryTextColor}"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
<Label Grid.Column="1"
|
||||||
|
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
|
||||||
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
|
FontSize="12"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
</Grid>
|
||||||
|
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Padding="16,14">
|
||||||
|
<Grid.GestureRecognizers>
|
||||||
|
<TapGestureRecognizer Command="{Binding Commands[RateApp]}" />
|
||||||
|
</Grid.GestureRecognizers>
|
||||||
|
<Label Grid.Column="0" Text="Rate PocketGraph"
|
||||||
|
TextColor="{DynamicResource PrimaryTextColor}"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
<Label Grid.Column="1"
|
||||||
|
Text="{x:Static fonts:FontAwesomeSolid.ChevronRight}"
|
||||||
|
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||||
|
FontSize="12"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}"
|
||||||
|
VerticalOptions="Center" />
|
||||||
|
</Grid>
|
||||||
|
</StackLayout>
|
||||||
|
</Border>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
<!-- About -->
|
<!-- About -->
|
||||||
<StackLayout Spacing="8">
|
<StackLayout Spacing="8">
|
||||||
<Label Text="ABOUT"
|
<Label Text="ABOUT"
|
||||||
@@ -147,23 +178,23 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<StackLayout Spacing="15" Padding="9,16,9,32">
|
||||||
|
<Label Text="A product of" HorizontalOptions="Center" FontAttributes="Italic" FontSize="12"
|
||||||
|
TextColor="{DynamicResource SecondaryTextColor}" />
|
||||||
|
<Border HorizontalOptions="Center"
|
||||||
|
BackgroundColor="#ffffff"
|
||||||
|
StrokeThickness="0"
|
||||||
|
StrokeShape="RoundRectangle 12"
|
||||||
|
Padding="16,8">
|
||||||
|
<Border.GestureRecognizers>
|
||||||
|
<TapGestureRecognizer Command="{Binding Commands[OpenReSoftwareSite]}" />
|
||||||
|
</Border.GestureRecognizers>
|
||||||
|
<Image Source="resoftware.png" WidthRequest="90" />
|
||||||
|
</Border>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<StackLayout Grid.Row="1" Spacing="4" Padding="25,16">
|
|
||||||
<Label HorizontalOptions="Center" FontSize="12">
|
|
||||||
<Label.GestureRecognizers>
|
|
||||||
<TapGestureRecognizer Command="{Binding Commands[OpenReSoftwareSite]}" />
|
|
||||||
</Label.GestureRecognizers>
|
|
||||||
<Label.FormattedText>
|
|
||||||
<FormattedString>
|
|
||||||
<Span Text="A product of " TextColor="{DynamicResource SecondaryTextColor}" FontAttributes="Italic" />
|
|
||||||
<Span Text="Re: Software" TextColor="{DynamicResource Accent}" FontAttributes="Bold" />
|
|
||||||
</FormattedString>
|
|
||||||
</Label.FormattedText>
|
|
||||||
</Label>
|
|
||||||
</StackLayout>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
</ContentPage.Content>
|
</ContentPage.Content>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|||||||
@@ -76,12 +76,18 @@ namespace Xamarin.Neo4j.Pages
|
|||||||
|
|
||||||
var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
|
var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
|
||||||
html = html.Replace("{{json}}", json);
|
html = html.Replace("{{json}}", json);
|
||||||
html = html.Replace("{{backgroundColor}}", isDark ? "#1e1e1e" : "#ffffff");
|
html = html.Replace("{{backgroundColor}}", isDark ? "#0c0c0c" : "#f5f5f5");
|
||||||
html = html.Replace("{{textColor}}", isDark ? "#d4d4d4" : "#1a1a1a");
|
html = html.Replace("{{textColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
|
||||||
html = html.Replace("{{toolbarBg}}", isDark ? "#252526" : "#f5f5f5");
|
html = html.Replace("{{toolbarBg}}", isDark ? "#141414" : "#ffffff");
|
||||||
html = html.Replace("{{inputBg}}", isDark ? "#3c3c3c" : "#ffffff");
|
html = html.Replace("{{inputBg}}", isDark ? "#1a1a1a" : "#ffffff");
|
||||||
html = html.Replace("{{borderColor}}", isDark ? "#454545" : "#cccccc");
|
html = html.Replace("{{borderColor}}", isDark ? "#2a2a2a" : "#e0e0e0");
|
||||||
html = html.Replace("{{mutedColor}}", isDark ? "#808080" : "#8a8a8a");
|
html = html.Replace("{{mutedColor}}", isDark ? "#868686" : "#868a92");
|
||||||
|
html = html.Replace("{{stringColor}}", isDark ? "#8BC34A" : "#2E7D32");
|
||||||
|
html = html.Replace("{{numberColor}}", isDark ? "#64B5F6" : "#1565C0");
|
||||||
|
html = html.Replace("{{boolColor}}", isDark ? "#FFB74D" : "#E65100");
|
||||||
|
html = html.Replace("{{nullColor}}", isDark ? "#868686" : "#868a92");
|
||||||
|
html = html.Replace("{{keyColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
|
||||||
|
html = html.Replace("{{errorColor}}", isDark ? "#ff6b6b" : "#c62828");
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
<Color x:Key="QueryTextBorder">#2a2a2a</Color>
|
<Color x:Key="QueryTextBorder">#2a2a2a</Color>
|
||||||
<Color x:Key="QueryActionBar">#141414</Color>
|
<Color x:Key="QueryActionBar">#141414</Color>
|
||||||
<Color x:Key="Extreme">#141414</Color>
|
<Color x:Key="Extreme">#141414</Color>
|
||||||
|
<Color x:Key="NavigationBarColor">#141414</Color>
|
||||||
|
<Color x:Key="FabBackgroundColor">#ffffff</Color>
|
||||||
|
<Color x:Key="FabTextColor">#0c0c0c</Color>
|
||||||
|
|
||||||
<Style TargetType="ContentPage" ApplyToDerivedTypes="True">
|
<Style TargetType="ContentPage" ApplyToDerivedTypes="True">
|
||||||
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
|
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style TargetType="NavigationPage">
|
<Style TargetType="NavigationPage">
|
||||||
<Setter Property="BarBackgroundColor" Value="#141414"/>
|
<Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
|
||||||
<Setter Property="BarTextColor" Value="#e2e2e2" />
|
<Setter Property="BarTextColor" Value="#e2e2e2" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
<Color x:Key="QueryTextBorder">#e0e0e0</Color>
|
<Color x:Key="QueryTextBorder">#e0e0e0</Color>
|
||||||
<Color x:Key="QueryActionBar">#f5f5f5</Color>
|
<Color x:Key="QueryActionBar">#f5f5f5</Color>
|
||||||
<Color x:Key="Extreme">#ffffff</Color>
|
<Color x:Key="Extreme">#ffffff</Color>
|
||||||
|
<Color x:Key="NavigationBarColor">#0c0c0c</Color>
|
||||||
|
<Color x:Key="FabBackgroundColor">#0c0c0c</Color>
|
||||||
|
<Color x:Key="FabTextColor">#ffffff</Color>
|
||||||
|
|
||||||
<Style TargetType="ContentPage" ApplyToDerivedTypes="True">
|
<Style TargetType="ContentPage" ApplyToDerivedTypes="True">
|
||||||
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
|
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
|
||||||
@@ -37,7 +40,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style TargetType="NavigationPage">
|
<Style TargetType="NavigationPage">
|
||||||
<Setter Property="BarBackgroundColor" Value="#0c0c0c"/>
|
<Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
|
||||||
<Setter Property="BarTextColor" Value="#e2e2e2" />
|
<Setter Property="BarTextColor" Value="#e2e2e2" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
{
|
{
|
||||||
private Guid? _id;
|
private Guid? _id;
|
||||||
|
|
||||||
private string _scheme, _host, _username, _password;
|
private string _name, _scheme, _host, _username, _password;
|
||||||
|
|
||||||
private readonly Neo4jService _neo4jService;
|
private readonly Neo4jService _neo4jService;
|
||||||
|
|
||||||
@@ -55,20 +55,13 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
{
|
{
|
||||||
var connectionString = BuildConnectionString();
|
var connectionString = BuildConnectionString();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString.Name))
|
||||||
|
connectionString.Name = connectionString.Host;
|
||||||
|
|
||||||
if (_id.HasValue)
|
if (_id.HasValue)
|
||||||
await ConnectionStringManager.UpdateConnectionString(_id.Value, connectionString);
|
await ConnectionStringManager.UpdateConnectionString(_id.Value, connectionString);
|
||||||
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
var name = await Application.Current.MainPage.DisplayPromptAsync("Save Connection", "How do you want to name this connection?", "Save", "Cancel");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
|
||||||
return;
|
|
||||||
|
|
||||||
connectionString.Name = name;
|
|
||||||
|
|
||||||
await ConnectionStringManager.AddConnectionString(connectionString);
|
await ConnectionStringManager.AddConnectionString(connectionString);
|
||||||
}
|
|
||||||
|
|
||||||
await Navigation.PopAsync();
|
await Navigation.PopAsync();
|
||||||
}));
|
}));
|
||||||
@@ -91,6 +84,7 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
{
|
{
|
||||||
_id = neo4JConnectionString.Id;
|
_id = neo4JConnectionString.Id;
|
||||||
|
|
||||||
|
Name = neo4JConnectionString.Name;
|
||||||
Scheme = neo4JConnectionString.Scheme;
|
Scheme = neo4JConnectionString.Scheme;
|
||||||
Host = neo4JConnectionString.Host;
|
Host = neo4JConnectionString.Host;
|
||||||
Username = neo4JConnectionString.Username;
|
Username = neo4JConnectionString.Username;
|
||||||
@@ -114,6 +108,7 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
return new Neo4jConnectionString
|
return new Neo4jConnectionString
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
Name = Name,
|
||||||
Scheme = Scheme,
|
Scheme = Scheme,
|
||||||
Host = Host,
|
Host = Host,
|
||||||
Username = Username,
|
Username = Username,
|
||||||
@@ -123,6 +118,16 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
|
|
||||||
#region Bindable Properties
|
#region Bindable Properties
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get => _name;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_name = value;
|
||||||
|
OnPropertyChanged(nameof(Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string Scheme
|
public string Scheme
|
||||||
{
|
{
|
||||||
get => _scheme;
|
get => _scheme;
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
LoadConnectionStrings();
|
LoadConnectionStrings();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Commands.Add("OpenConnection", new Command((o) =>
|
||||||
|
{
|
||||||
|
if (o is Neo4jConnectionString cs)
|
||||||
|
OpenSession(cs);
|
||||||
|
}));
|
||||||
|
|
||||||
Commands.Add("OpenSettings", new Command(async () =>
|
Commands.Add("OpenSettings", new Command(async () =>
|
||||||
{
|
{
|
||||||
await Navigation.PushAsync(new SettingsPage());
|
await Navigation.PushAsync(new SettingsPage());
|
||||||
@@ -87,6 +93,7 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
OnPropertyChanged(nameof(ConnectionStrings));
|
OnPropertyChanged(nameof(ConnectionStrings));
|
||||||
OnPropertyChanged(nameof(IsEmpty));
|
OnPropertyChanged(nameof(IsEmpty));
|
||||||
OnPropertyChanged(nameof(HasItems));
|
OnPropertyChanged(nameof(HasItems));
|
||||||
|
OnPropertyChanged(nameof(ConnectionCountLabel));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +101,20 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
|
|
||||||
public bool HasItems => !IsEmpty;
|
public bool HasItems => !IsEmpty;
|
||||||
|
|
||||||
|
public string ConnectionCountLabel
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var count = _connectionStrings?.Count() ?? 0;
|
||||||
|
return count switch
|
||||||
|
{
|
||||||
|
0 => "No connections yet",
|
||||||
|
1 => "1 connection",
|
||||||
|
_ => $"{count} connections"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
var screenHeight = _screenSizeService.GetScreenHeight();
|
var screenHeight = _screenSizeService.GetScreenHeight();
|
||||||
return Math.Max(200, screenHeight - 300);
|
return Math.Max(120, (screenHeight - 300) / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,26 @@ namespace Xamarin.Neo4j.ViewModels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool AutoCapitalize
|
||||||
|
{
|
||||||
|
get => Preferences.Default.Get("auto_capitalize", true);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Preferences.Default.Set("auto_capitalize", value);
|
||||||
|
OnPropertyChanged(nameof(AutoCapitalize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AutoZoom
|
||||||
|
{
|
||||||
|
get => Preferences.Default.Get("auto_zoom", true);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Preferences.Default.Set("auto_zoom", value);
|
||||||
|
OnPropertyChanged(nameof(AutoZoom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:{{backgroundColor}};--fg:{{textColor}};--toolbar:{{toolbarBg}};--input:{{inputBg}};--border:{{borderColor}};--muted:{{mutedColor}}}
|
:root{--bg:{{backgroundColor}};--fg:{{textColor}};--toolbar:{{toolbarBg}};--input:{{inputBg}};--border:{{borderColor}};--muted:{{mutedColor}};--str:{{stringColor}};--num:{{numberColor}};--bool:{{boolColor}};--nul:{{nullColor}};--key:{{keyColor}};--err:{{errorColor}}}
|
||||||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||||
body{font-family:'Courier New',monospace;font-size:13px;background:var(--bg);color:var(--fg);overscroll-behavior:none}
|
body{font-family:'Courier New',monospace;font-size:14px;line-height:1.5;background:var(--bg);color:var(--fg);overscroll-behavior:none}
|
||||||
#toolbar{position:sticky;top:0;z-index:10;background:var(--toolbar);padding:8px;display:flex;flex-direction:column;gap:6px;border-bottom:1px solid var(--border)}
|
#toolbar{position:sticky;top:0;z-index:10;background:var(--toolbar);padding:8px;display:flex;flex-direction:column;gap:6px;border-bottom:1px solid var(--border)}
|
||||||
#toolbar input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input);color:var(--fg);font-family:inherit;font-size:13px;outline:none;-webkit-appearance:none}
|
#toolbar input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input);color:var(--fg);font-family:inherit;font-size:13px;outline:none;-webkit-appearance:none}
|
||||||
#toolbar-row2{display:flex;gap:6px;align-items:center}
|
#toolbar-row2{display:flex;gap:6px;align-items:center}
|
||||||
@@ -14,8 +14,8 @@ body{font-family:'Courier New',monospace;font-size:13px;background:var(--bg);col
|
|||||||
.tbtn:active{opacity:.7}
|
.tbtn:active{opacity:.7}
|
||||||
#status{font-size:11px;color:var(--muted);padding:0 2px;min-height:15px}
|
#status{font-size:11px;color:var(--muted);padding:0 2px;min-height:15px}
|
||||||
#output{padding:10px}
|
#output{padding:10px}
|
||||||
.s{color:#4CAF50}.n{color:#64B5F6}.b{color:#FFB74D}.nl{color:#90A4AE}.k{color:#EF9A9A}.e{color:#EF5350}
|
.s{color:var(--str)}.n{color:var(--num)}.b{color:var(--bool)}.nl{color:var(--nul)}.k{color:var(--key);font-weight:600}.e{color:var(--err)}
|
||||||
.br{color:var(--fg);opacity:.7}.cm{color:var(--fg);opacity:.4}
|
.br{color:var(--fg);opacity:.85}.cm{color:var(--fg);opacity:.5}
|
||||||
/* Block-based tree nodes */
|
/* Block-based tree nodes */
|
||||||
.node,.knode{display:block}
|
.node,.knode{display:block}
|
||||||
.nhd{display:block;cursor:default}
|
.nhd{display:block;cursor:default}
|
||||||
@@ -154,13 +154,19 @@ function count(v){
|
|||||||
|
|
||||||
function setTheme(isDark){
|
function setTheme(isDark){
|
||||||
const r=document.documentElement.style;
|
const r=document.documentElement.style;
|
||||||
r.setProperty('--bg',isDark?'#1e1e1e':'#ffffff');
|
r.setProperty('--bg',isDark?'#0c0c0c':'#f5f5f5');
|
||||||
r.setProperty('--fg',isDark?'#d4d4d4':'#1a1a1a');
|
r.setProperty('--fg',isDark?'#e2e2e2':'#0c0c0c');
|
||||||
r.setProperty('--toolbar',isDark?'#252526':'#f5f5f5');
|
r.setProperty('--toolbar',isDark?'#141414':'#ffffff');
|
||||||
r.setProperty('--input',isDark?'#3c3c3c':'#ffffff');
|
r.setProperty('--input',isDark?'#1a1a1a':'#ffffff');
|
||||||
r.setProperty('--border',isDark?'#454545':'#cccccc');
|
r.setProperty('--border',isDark?'#2a2a2a':'#e0e0e0');
|
||||||
r.setProperty('--muted',isDark?'#808080':'#8a8a8a');
|
r.setProperty('--muted',isDark?'#868686':'#868a92');
|
||||||
document.body.style.background=isDark?'#1e1e1e':'#ffffff';
|
r.setProperty('--str',isDark?'#8BC34A':'#2E7D32');
|
||||||
|
r.setProperty('--num',isDark?'#64B5F6':'#1565C0');
|
||||||
|
r.setProperty('--bool',isDark?'#FFB74D':'#E65100');
|
||||||
|
r.setProperty('--nul',isDark?'#868686':'#868a92');
|
||||||
|
r.setProperty('--key',isDark?'#e2e2e2':'#0c0c0c');
|
||||||
|
r.setProperty('--err',isDark?'#ff6b6b':'#c62828');
|
||||||
|
document.body.style.background=isDark?'#0c0c0c':'#f5f5f5';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('srch').addEventListener('input',function(){srch=this.value.trim().toLowerCase();render()});
|
document.getElementById('srch').addEventListener('input',function(){srch=this.value.trim().toLowerCase();render()});
|
||||||
|
|||||||
@@ -2,42 +2,281 @@
|
|||||||
<html lang="en-US">
|
<html lang="en-US">
|
||||||
<head>
|
<head>
|
||||||
<title>Graph</title>
|
<title>Graph</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; }
|
html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; }
|
||||||
canvas { display: block; position: absolute; top: 0; left: 0; }
|
canvas { display: block; position: absolute; top: 0; left: 0; }
|
||||||
|
|
||||||
#popup {
|
/* ── Slide-up detail panel ── */
|
||||||
|
#panel-backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
top: 0;
|
||||||
left: 16px;
|
left: 0;
|
||||||
right: 16px;
|
right: 0;
|
||||||
background: rgba(20, 20, 30, 0.95);
|
bottom: 55%;
|
||||||
color: #fff;
|
z-index: 90;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: {{panelBg}};
|
||||||
|
border-top: 1px solid {{borderColor}};
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
height: 55%;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1), height 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
will-change: transform, height;
|
||||||
|
}
|
||||||
|
#panel.open { transform: translateY(0); }
|
||||||
|
#panel.dragging { transition: none !important; }
|
||||||
|
|
||||||
|
/* Drag handle */
|
||||||
|
.panel-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: {{mutedColor}};
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 10px auto 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.panel-handle-zone {
|
||||||
|
padding: 6px 0 2px;
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons row */
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid {{borderColor}};
|
||||||
|
}
|
||||||
|
.panel-actions button {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: {{mutedColor}};
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.panel-actions button:active {
|
||||||
|
background: {{borderColor}};
|
||||||
|
}
|
||||||
|
.panel-actions button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: {{accentColor}};
|
||||||
|
}
|
||||||
|
.panel-actions button.destructive svg { fill: {{errorColor}}; }
|
||||||
|
|
||||||
|
/* Label header */
|
||||||
|
.panel-label {
|
||||||
|
padding: 12px 16px 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.panel-label .node-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px 16px;
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.panel-label .node-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: {{mutedColor}};
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.panel-label .edge-type {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: {{primaryColor}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable properties */
|
||||||
|
.panel-props {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 16px 24px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.panel-props table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.panel-props td {
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid {{borderColor}};
|
||||||
|
}
|
||||||
|
.panel-props td.pk {
|
||||||
|
color: {{mutedColor}};
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 12px;
|
||||||
|
width: 1%;
|
||||||
|
}
|
||||||
|
.panel-props td.pv {
|
||||||
|
color: {{primaryColor}};
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.panel-props tr:last-child td { border-bottom: none; }
|
||||||
|
.panel-props .no-props {
|
||||||
|
color: {{mutedColor}};
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JSON view */
|
||||||
|
.json-view-wrapper {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.json-view-wrapper pre {
|
||||||
|
background: {{borderColor}};
|
||||||
|
color: {{primaryColor}};
|
||||||
|
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 36px 12px 12px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 40vh;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.json-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: {{panelBg}};
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.json-copy-btn:active { opacity: 1; }
|
||||||
|
.json-copy-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: {{accentColor}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Relations list */
|
||||||
|
.relations-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.relations-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid {{borderColor}};
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.relations-list li:last-child { border-bottom: none; }
|
||||||
|
.rel-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: {{accentColor}};
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: {{borderColor}};
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rel-arrow {
|
||||||
|
color: {{mutedColor}};
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rel-node {
|
||||||
|
color: {{primaryColor}};
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.rel-empty {
|
||||||
|
color: {{mutedColor}};
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notification */
|
||||||
|
#toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
background: {{panelBg}};
|
||||||
|
color: {{primaryColor}};
|
||||||
|
border: 1px solid {{borderColor}};
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
font-family: -apple-system, sans-serif;
|
font-family: -apple-system, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
opacity: 0;
|
||||||
max-height: 40%;
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
overflow-y: auto;
|
z-index: 200;
|
||||||
z-index: 100;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#popup .lbl {
|
#toast.show {
|
||||||
font-size: 15px;
|
opacity: 1;
|
||||||
font-weight: 600;
|
transform: translateX(-50%) translateY(0);
|
||||||
margin-bottom: 6px;
|
|
||||||
color: #7EC8E3;
|
|
||||||
}
|
}
|
||||||
#popup .prop { color: #ccc; }
|
|
||||||
#popup .prop b { color: #fff; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="c"></canvas>
|
<canvas id="c"></canvas>
|
||||||
<div id="popup"></div>
|
|
||||||
|
<!-- Backdrop to catch taps outside panel -->
|
||||||
|
<div id="panel-backdrop"></div>
|
||||||
|
|
||||||
|
<!-- Slide-up detail panel -->
|
||||||
|
<div id="panel">
|
||||||
|
<div class="panel-handle-zone" id="panel-handle-zone">
|
||||||
|
<div class="panel-handle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions" id="panel-actions"></div>
|
||||||
|
<div class="panel-label" id="panel-label"></div>
|
||||||
|
<div class="panel-props" id="panel-props"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -51,7 +290,12 @@
|
|||||||
|
|
||||||
var canvas = document.getElementById('c');
|
var canvas = document.getElementById('c');
|
||||||
var ctx = canvas.getContext('2d');
|
var ctx = canvas.getContext('2d');
|
||||||
var popup = document.getElementById('popup');
|
var panel = document.getElementById('panel');
|
||||||
|
var panelBackdrop = document.getElementById('panel-backdrop');
|
||||||
|
var panelActions = document.getElementById('panel-actions');
|
||||||
|
var panelLabel = document.getElementById('panel-label');
|
||||||
|
var panelProps = document.getElementById('panel-props');
|
||||||
|
var panelHandleZone = document.getElementById('panel-handle-zone');
|
||||||
var W = 0, H = 0, dpr = window.devicePixelRatio || 1;
|
var W = 0, H = 0, dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
// ---- node/edge state ----
|
// ---- node/edge state ----
|
||||||
@@ -134,8 +378,6 @@
|
|||||||
edges.forEach(function (e) {
|
edges.forEach(function (e) {
|
||||||
a = nodeById[e.from]; b = nodeById[e.to];
|
a = nodeById[e.from]; b = nodeById[e.to];
|
||||||
if (!a || !b) return;
|
if (!a || !b) return;
|
||||||
// leaf-to-hub: short spring (leaf stays close as petal)
|
|
||||||
// hub-to-hub: long spring (clusters separate)
|
|
||||||
var len;
|
var len;
|
||||||
if (a.isHub && b.isHub) {
|
if (a.isHub && b.isHub) {
|
||||||
len = HUB_SPRING;
|
len = HUB_SPRING;
|
||||||
@@ -190,6 +432,35 @@
|
|||||||
// ---- camera (pan + zoom, initial zoom based on node count) ----
|
// ---- camera (pan + zoom, initial zoom based on node count) ----
|
||||||
var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10));
|
var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10));
|
||||||
var panX = 0, panY = 0, zoom = initialZoom;
|
var panX = 0, panY = 0, zoom = initialZoom;
|
||||||
|
var autoZoomEnabled = {{autoZoom}};
|
||||||
|
|
||||||
|
// Smoothly animate camera to center on a node
|
||||||
|
function zoomToNode(n, targetZoom, delay) {
|
||||||
|
if (!autoZoomEnabled) return;
|
||||||
|
function doZoom() {
|
||||||
|
targetZoom = targetZoom || Math.max(zoom, 1.2);
|
||||||
|
var startPanX = panX, startPanY = panY, startZoom = zoom;
|
||||||
|
// If panel is open, center in the visible area above the panel
|
||||||
|
var panelH = panelOpen ? (panel.offsetHeight || H * 0.55) : 0;
|
||||||
|
var visibleCenterY = (H - panelH) / 2;
|
||||||
|
var endPanX = W / 2 - n.x * targetZoom;
|
||||||
|
var endPanY = visibleCenterY - n.y * targetZoom;
|
||||||
|
var duration = 400, startTime = Date.now();
|
||||||
|
function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
|
||||||
|
function animate() {
|
||||||
|
var t = Math.min(1, (Date.now() - startTime) / duration);
|
||||||
|
var p = ease(t);
|
||||||
|
panX = startPanX + (endPanX - startPanX) * p;
|
||||||
|
panY = startPanY + (endPanY - startPanY) * p;
|
||||||
|
zoom = startZoom + (targetZoom - startZoom) * p;
|
||||||
|
draw();
|
||||||
|
if (t < 1) requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
if (delay) setTimeout(doZoom, delay);
|
||||||
|
else doZoom();
|
||||||
|
}
|
||||||
|
|
||||||
// screen coords -> world coords
|
// screen coords -> world coords
|
||||||
function toWorld(sx, sy) {
|
function toWorld(sx, sy) {
|
||||||
@@ -257,7 +528,7 @@
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
var lbl = n.label.length > 10 ? n.label.slice(0, 9) + '…' : n.label;
|
var lbl = n.label.length > 10 ? n.label.slice(0, 9) + '\u2026' : n.label;
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.font = 'bold 11px -apple-system, sans-serif';
|
ctx.font = 'bold 11px -apple-system, sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
@@ -267,57 +538,396 @@
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- popup ----
|
// ---- panel ----
|
||||||
function buildPropsHtml(title) {
|
var panelOpen = false;
|
||||||
var html = '';
|
var panelViewState = 'props'; // 'props', 'json', 'relations'
|
||||||
if (title) {
|
var panelCurrentItem = null;
|
||||||
|
var panelCurrentType = null; // 'node' or 'edge'
|
||||||
|
|
||||||
|
// SVG icon paths
|
||||||
|
var ICONS = {
|
||||||
|
copy: '<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>',
|
||||||
|
json: '<svg viewBox="0 0 24 24"><path d="M5 3h2v2H5v5a2 2 0 01-2 2 2 2 0 012 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 00-2-2H0v-2h1a2 2 0 002-2V5a2 2 0 012-2m14 0a2 2 0 012 2v4a2 2 0 002 2h1v2h-1a2 2 0 00-2 2v4a2 2 0 01-2 2h-2v-2h2v-5a2 2 0 012-2 2 2 0 01-2-2V5h-2V3h2z"/></svg>',
|
||||||
|
expand: '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15v-4H7v-2h4V7h2v4h4v2h-4v4h-2z"/></svg>',
|
||||||
|
collapse: '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"/></svg>',
|
||||||
|
close: '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>',
|
||||||
|
props: '<svg viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseProps(title) {
|
||||||
|
var props = [];
|
||||||
|
if (!title) return props;
|
||||||
title.split(', ').forEach(function (pair) {
|
title.split(', ').forEach(function (pair) {
|
||||||
var idx = pair.indexOf(': ');
|
var idx = pair.indexOf(': ');
|
||||||
if (idx > -1) {
|
if (idx > -1) {
|
||||||
html += '<div class="prop"><b>' + escHtml(pair.slice(0, idx)) + ':</b> ' + escHtml(pair.slice(idx + 2)) + '</div>';
|
props.push({ key: pair.slice(0, idx), value: pair.slice(idx + 2) });
|
||||||
} else {
|
} else if (pair.trim()) {
|
||||||
html += '<div class="prop">' + escHtml(pair) + '</div>';
|
props.push({ key: '', value: pair });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
return props;
|
||||||
html += '<div class="prop">No properties</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function propsToText(label, props) {
|
||||||
|
var lines = [label];
|
||||||
|
props.forEach(function (p) {
|
||||||
|
lines.push(p.key ? p.key + ': ' + p.value : p.value);
|
||||||
|
});
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function propsToJson(n) {
|
||||||
|
var obj = { id: n.id, label: n.label, properties: {} };
|
||||||
|
parseProps(n.title).forEach(function (p) {
|
||||||
|
if (p.key) obj.properties[p.key] = p.value;
|
||||||
|
});
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgePropsToJson(e) {
|
||||||
|
var obj = { type: e.label, from: e.from, to: e.to, properties: {} };
|
||||||
|
parseProps(e.title).forEach(function (p) {
|
||||||
|
if (p.key) obj.properties[p.key] = p.value;
|
||||||
|
});
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
var t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('show');
|
||||||
|
setTimeout(function () { t.classList.remove('show'); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
showToast('Copied to clipboard');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for WebView
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
showToast('Copied to clipboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelationsForNode(nodeId) {
|
||||||
|
var rels = [];
|
||||||
|
edges.forEach(function (e) {
|
||||||
|
if (e.from === nodeId) {
|
||||||
|
var targetNode = nodeById[e.to];
|
||||||
|
rels.push({
|
||||||
|
type: e.label || 'RELATED',
|
||||||
|
direction: 'out',
|
||||||
|
nodeLabel: targetNode ? targetNode.label : String(e.to),
|
||||||
|
nodeId: e.to
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (e.to === nodeId) {
|
||||||
|
var sourceNode = nodeById[e.from];
|
||||||
|
rels.push({
|
||||||
|
type: e.label || 'RELATED',
|
||||||
|
direction: 'in',
|
||||||
|
nodeLabel: sourceNode ? sourceNode.label : String(e.from),
|
||||||
|
nodeId: e.from
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return rels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelationsHtml(nodeId) {
|
||||||
|
var rels = getRelationsForNode(nodeId);
|
||||||
|
if (rels.length === 0) {
|
||||||
|
return '<div class="rel-empty">Loading relations\u2026</div>';
|
||||||
|
}
|
||||||
|
var html = '<ul class="relations-list">';
|
||||||
|
rels.forEach(function (r) {
|
||||||
|
var arrow = r.direction === 'out' ? '\u2192' : '\u2190';
|
||||||
|
html += '<li>';
|
||||||
|
html += '<span class="rel-type">' + escHtml(r.type) + '</span>';
|
||||||
|
html += '<span class="rel-arrow">' + arrow + '</span>';
|
||||||
|
html += '<span class="rel-node">' + escHtml(r.nodeLabel) + '</span>';
|
||||||
|
html += '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPopup(n) {
|
function buildJsonViewHtml(jsonStr) {
|
||||||
|
return '<div class="json-view-wrapper">' +
|
||||||
|
'<button class="json-copy-btn" onclick="window._panelCopyJsonClipboard()">' + ICONS.copy + '</button>' +
|
||||||
|
'<pre>' + escHtml(jsonStr) + '</pre>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionsForView(type, item, viewState) {
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
// Copy as text
|
||||||
|
html += '<button onclick="window._panelCopyText()">' + ICONS.copy + '<span>Copy</span></button>';
|
||||||
|
|
||||||
|
// JSON / Properties toggle
|
||||||
|
if (viewState === 'json') {
|
||||||
|
html += '<button onclick="window._panelShowProps()">' + ICONS.props + '<span>Properties</span></button>';
|
||||||
|
} else {
|
||||||
|
html += '<button onclick="window._panelShowJson()">' + ICONS.json + '<span>JSON</span></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'node') {
|
||||||
|
if (viewState === 'relations') {
|
||||||
|
// Show Properties button to go back
|
||||||
|
html += '<button onclick="window._panelShowProps()">' + ICONS.props + '<span>Properties</span></button>';
|
||||||
|
} else {
|
||||||
|
// Expand / collapse relations
|
||||||
|
var isExpanded = !!expandedBy[item.id];
|
||||||
|
if (isExpanded) {
|
||||||
|
html += '<button onclick="window._panelCollapse()">' + ICONS.collapse + '<span>Collapse</span></button>';
|
||||||
|
} else {
|
||||||
|
html += '<button onclick="window._panelExpand()">' + ICONS.expand + '<span>Relations</span></button>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close
|
||||||
|
html += '<button class="destructive" onclick="window._panelClose()">' + ICONS.close + '<span>Close</span></button>';
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPropsHtml(title) {
|
||||||
|
var props = parseProps(title);
|
||||||
|
if (!props.length) return '<div class="no-props">No properties</div>';
|
||||||
|
var html = '<table>';
|
||||||
|
props.forEach(function (p) {
|
||||||
|
html += '<tr><td class="pk">' + escHtml(p.key) + '</td><td class="pv">' + escHtml(p.value) + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</table>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanelContent() {
|
||||||
|
if (!panelCurrentItem) return;
|
||||||
|
var item = panelCurrentItem;
|
||||||
|
var type = panelCurrentType;
|
||||||
|
|
||||||
|
if (panelViewState === 'json') {
|
||||||
|
var jsonStr = type === 'node' ? propsToJson(item) : edgePropsToJson(item);
|
||||||
|
panelProps.innerHTML = buildJsonViewHtml(jsonStr);
|
||||||
|
window._panelCopyJsonClipboard = function () {
|
||||||
|
copyToClipboard(jsonStr);
|
||||||
|
};
|
||||||
|
} else if (panelViewState === 'relations' && type === 'node') {
|
||||||
|
panelProps.innerHTML = buildRelationsHtml(item.id);
|
||||||
|
} else {
|
||||||
|
panelProps.innerHTML = buildPropsHtml(item.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
panelActions.innerHTML = buildActionsForView(type, item, panelViewState);
|
||||||
|
wireUpActions(type, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireUpActions(type, item) {
|
||||||
|
window._panelCopyText = function () {
|
||||||
|
var label = type === 'node' ? item.label : (item.label || 'Relationship');
|
||||||
|
copyToClipboard(propsToText(label, parseProps(item.title)));
|
||||||
|
};
|
||||||
|
window._panelShowJson = function () {
|
||||||
|
panelViewState = 'json';
|
||||||
|
updatePanelContent();
|
||||||
|
};
|
||||||
|
window._panelShowProps = function () {
|
||||||
|
panelViewState = 'props';
|
||||||
|
updatePanelContent();
|
||||||
|
};
|
||||||
|
window._panelClose = function () { hidePanel(); };
|
||||||
|
|
||||||
|
if (type === 'node') {
|
||||||
|
window._panelExpand = function () {
|
||||||
|
// Trigger expand
|
||||||
|
toggleExpandCollapse(item);
|
||||||
|
// Switch to relations view
|
||||||
|
panelViewState = 'relations';
|
||||||
|
updatePanelContent();
|
||||||
|
// After expand completes, the addGraphData callback will refresh
|
||||||
|
setTimeout(function () {
|
||||||
|
if (panelOpen && panelViewState === 'relations') {
|
||||||
|
updatePanelContent();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
window._panelCollapse = function () {
|
||||||
|
toggleExpandCollapse(item);
|
||||||
|
setTimeout(function () {
|
||||||
|
updatePanelContent();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNodePanel(n) {
|
||||||
selected = n;
|
selected = n;
|
||||||
selectedEdge = null;
|
selectedEdge = null;
|
||||||
var html = '<div class="lbl">' + escHtml(n.label) + '</div>';
|
panelCurrentItem = n;
|
||||||
html += buildPropsHtml(n.title);
|
panelCurrentType = 'node';
|
||||||
popup.innerHTML = html;
|
panelViewState = 'props';
|
||||||
popup.style.display = 'block';
|
|
||||||
|
// Label header
|
||||||
|
panelLabel.innerHTML = '<span class="node-badge" style="background:' + n.color + '">' + escHtml(n.label) + '</span>' +
|
||||||
|
'<span class="node-id">ID: ' + n.id + '</span>';
|
||||||
|
|
||||||
|
updatePanelContent();
|
||||||
|
openPanel();
|
||||||
|
zoomToNode(n);
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEdgePopup(e) {
|
function showEdgePanel(e) {
|
||||||
selectedEdge = e;
|
selectedEdge = e;
|
||||||
selected = null;
|
selected = null;
|
||||||
var html = '<div class="lbl" style="color:#E8A838">' + escHtml(e.label || 'Relationship') + '</div>';
|
panelCurrentItem = e;
|
||||||
html += buildPropsHtml(e.title);
|
panelCurrentType = 'edge';
|
||||||
popup.innerHTML = html;
|
panelViewState = 'props';
|
||||||
popup.style.display = 'block';
|
|
||||||
|
// Label
|
||||||
|
panelLabel.innerHTML = '<span class="edge-type">' + escHtml(e.label || 'Relationship') + '</span>' +
|
||||||
|
'<span class="node-id" style="margin-left:8px">' + e.from + ' \u2192 ' + e.to + '</span>';
|
||||||
|
|
||||||
|
updatePanelContent();
|
||||||
|
openPanel();
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hidePopup() {
|
function notifyApp(url) {
|
||||||
|
var f = document.createElement('iframe');
|
||||||
|
f.style.display = 'none';
|
||||||
|
f.src = url;
|
||||||
|
document.body.appendChild(f);
|
||||||
|
setTimeout(function () { document.body.removeChild(f); }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
panelOpen = true;
|
||||||
|
panel.style.height = '55%';
|
||||||
|
panelBackdrop.style.display = 'block';
|
||||||
|
panel.offsetHeight;
|
||||||
|
panel.classList.add('open');
|
||||||
|
notifyApp('app://panelopen');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePanel() {
|
||||||
selected = null;
|
selected = null;
|
||||||
selectedEdge = null;
|
selectedEdge = null;
|
||||||
popup.style.display = 'none';
|
panelOpen = false;
|
||||||
|
panelCurrentItem = null;
|
||||||
|
panelCurrentType = null;
|
||||||
|
panelViewState = 'props';
|
||||||
|
panel.classList.remove('open');
|
||||||
|
panel.style.transform = '';
|
||||||
|
panel.style.height = '';
|
||||||
|
panelBackdrop.style.display = 'none';
|
||||||
draw();
|
draw();
|
||||||
|
notifyApp('app://panelclose');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
panelBackdrop.addEventListener('click', function (e) { e.stopPropagation(); hidePanel(); });
|
||||||
|
panelBackdrop.addEventListener('touchend', function (e) { e.stopPropagation(); hidePanel(); });
|
||||||
|
|
||||||
|
// ---- panel drag (up to expand, down to dismiss) ----
|
||||||
|
var panelDragStartY = 0;
|
||||||
|
var panelDragDeltaY = 0;
|
||||||
|
var panelIsDragging = false;
|
||||||
|
var panelStartHeight = 0;
|
||||||
|
var PANEL_MIN_H = 120;
|
||||||
|
|
||||||
|
function panelDragStart(clientY, e) {
|
||||||
|
if (!panelOpen) return;
|
||||||
|
panelDragStartY = clientY;
|
||||||
|
panelDragDeltaY = 0;
|
||||||
|
panelStartHeight = panel.offsetHeight;
|
||||||
|
panelIsDragging = true;
|
||||||
|
panel.classList.add('dragging');
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
panelHandleZone.addEventListener('touchstart', function (e) {
|
||||||
|
panelDragStart(e.touches[0].clientY, e);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
panelHandleZone.addEventListener('mousedown', function (e) {
|
||||||
|
panelDragStart(e.clientY, e);
|
||||||
|
});
|
||||||
|
|
||||||
|
function panelDragMove(clientY) {
|
||||||
|
if (!panelIsDragging) return;
|
||||||
|
var dy = clientY - panelDragStartY;
|
||||||
|
panelDragDeltaY = dy;
|
||||||
|
if (dy >= 0) {
|
||||||
|
// Dragging down — translate panel down (toward dismiss)
|
||||||
|
panel.style.height = panelStartHeight + 'px';
|
||||||
|
panel.style.transform = 'translateY(' + dy + 'px)';
|
||||||
|
} else {
|
||||||
|
// Dragging up — expand panel height
|
||||||
|
var newH = Math.min(panelStartHeight - dy, H * 0.92);
|
||||||
|
newH = Math.max(newH, PANEL_MIN_H);
|
||||||
|
panel.style.height = newH + 'px';
|
||||||
|
panel.style.transform = 'translateY(0)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelDragEnd() {
|
||||||
|
if (!panelIsDragging) return;
|
||||||
|
panelIsDragging = false;
|
||||||
|
panel.classList.remove('dragging');
|
||||||
|
if (panelDragDeltaY > 80) {
|
||||||
|
hidePanel();
|
||||||
|
} else {
|
||||||
|
// Keep the new height if dragged up, snap translate back
|
||||||
|
panel.style.transform = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', function (e) {
|
||||||
|
if (panelIsDragging) {
|
||||||
|
panelDragMove(e.touches[0].clientY);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
document.addEventListener('touchend', function () {
|
||||||
|
if (panelIsDragging) panelDragEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', function (e) {
|
||||||
|
if (panelIsDragging) {
|
||||||
|
panelDragMove(e.clientY);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', function () {
|
||||||
|
if (panelIsDragging) panelDragEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', function (e) {
|
||||||
|
if (panelIsDragging) {
|
||||||
|
panelDragEnd();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- expand / collapse ----
|
// ---- expand / collapse ----
|
||||||
var lastTapTime = 0, lastTapNode = null;
|
var lastTapTime = 0, lastTapNode = null, singleTapTimer = null;
|
||||||
var expandedBy = {}; // nodeId -> [childIds added by expanding that node]
|
var expandedBy = {}; // nodeId -> [childIds added by expanding that node]
|
||||||
|
|
||||||
// initial nodes are never collapsible
|
// initial nodes are never collapsible
|
||||||
@@ -346,19 +956,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expandNode(n) {
|
function expandNode(n) {
|
||||||
var f = document.createElement('iframe');
|
notifyApp('app://expand?nodeId=' + n.id);
|
||||||
f.style.display = 'none';
|
|
||||||
f.src = 'app://expand?nodeId=' + n.id;
|
|
||||||
document.body.appendChild(f);
|
|
||||||
setTimeout(function () { document.body.removeChild(f); }, 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseNode(n) {
|
function collapseNode(n) {
|
||||||
var childIds = expandedBy[n.id];
|
var childIds = expandedBy[n.id];
|
||||||
if (!childIds) return;
|
if (!childIds) return;
|
||||||
|
|
||||||
// only remove children that aren't initial nodes and aren't
|
|
||||||
// also owned by another still-expanded node
|
|
||||||
var otherOwned = {};
|
var otherOwned = {};
|
||||||
Object.keys(expandedBy).forEach(function (ownerId) {
|
Object.keys(expandedBy).forEach(function (ownerId) {
|
||||||
if (ownerId == n.id) return;
|
if (ownerId == n.id) return;
|
||||||
@@ -370,7 +974,6 @@
|
|||||||
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true;
|
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// also collapse any nodes that were expanded from nodes we're about to remove
|
|
||||||
Object.keys(toRemove).forEach(function (rid) {
|
Object.keys(toRemove).forEach(function (rid) {
|
||||||
if (expandedBy[rid]) {
|
if (expandedBy[rid]) {
|
||||||
expandedBy[rid].forEach(function (cid) {
|
expandedBy[rid].forEach(function (cid) {
|
||||||
@@ -380,19 +983,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// remove edges connected to removed nodes
|
|
||||||
edges = edges.filter(function (e) {
|
edges = edges.filter(function (e) {
|
||||||
return !toRemove[e.from] && !toRemove[e.to];
|
return !toRemove[e.from] && !toRemove[e.to];
|
||||||
});
|
});
|
||||||
|
|
||||||
// remove nodes
|
|
||||||
nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; });
|
nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; });
|
||||||
Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; });
|
Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; });
|
||||||
|
|
||||||
delete expandedBy[n.id];
|
delete expandedBy[n.id];
|
||||||
|
|
||||||
// hide popup if the selected node was removed
|
if (selected && toRemove[selected.id]) hidePanel();
|
||||||
if (selected && toRemove[selected.id]) hidePopup();
|
|
||||||
|
|
||||||
recomputeDegrees();
|
recomputeDegrees();
|
||||||
resumeLoop();
|
resumeLoop();
|
||||||
@@ -407,7 +1007,7 @@
|
|||||||
|
|
||||||
newNodes.forEach(function (d) {
|
newNodes.forEach(function (d) {
|
||||||
if (nodeById[d.id]) {
|
if (nodeById[d.id]) {
|
||||||
addedIds.push(d.id); // track even existing ones as children
|
addedIds.push(d.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var angle = Math.random() * 2 * Math.PI;
|
var angle = Math.random() * 2 * Math.PI;
|
||||||
@@ -429,13 +1029,22 @@
|
|||||||
});
|
});
|
||||||
if (exists) return;
|
if (exists) return;
|
||||||
if (!nodeById[d.from] || !nodeById[d.to]) return;
|
if (!nodeById[d.from] || !nodeById[d.to]) return;
|
||||||
edges.push({ from: d.from, to: d.to, label: d.label || '' });
|
edges.push({ from: d.from, to: d.to, label: d.label || '', title: d.title || '' });
|
||||||
});
|
});
|
||||||
|
|
||||||
expandedBy[originId] = addedIds;
|
expandedBy[originId] = addedIds;
|
||||||
|
|
||||||
recomputeDegrees();
|
recomputeDegrees();
|
||||||
resumeLoop();
|
resumeLoop();
|
||||||
|
|
||||||
|
// Zoom to the expanded node
|
||||||
|
var origin = nodeById[originId];
|
||||||
|
if (origin) zoomToNode(origin, null, 800);
|
||||||
|
|
||||||
|
// Update panel if still showing this node
|
||||||
|
if (selected && selected.id === originId && panelOpen) {
|
||||||
|
updatePanelContent();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- interaction handling (touch + mouse/pointer) ----
|
// ---- interaction handling (touch + mouse/pointer) ----
|
||||||
@@ -444,21 +1053,16 @@
|
|||||||
function notifyInteraction() {
|
function notifyInteraction() {
|
||||||
if (interacted) return;
|
if (interacted) return;
|
||||||
interacted = true;
|
interacted = true;
|
||||||
var f = document.createElement('iframe');
|
notifyApp('app://interaction');
|
||||||
f.style.display = 'none';
|
|
||||||
f.src = 'app://interaction';
|
|
||||||
document.body.appendChild(f);
|
|
||||||
setTimeout(function () { document.body.removeChild(f); }, 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function edgeAt(sx, sy) {
|
function edgeAt(sx, sy) {
|
||||||
var w = toWorld(sx, sy);
|
var w = toWorld(sx, sy);
|
||||||
var threshold = 12 / zoom; // tap tolerance in world units
|
var threshold = 12 / zoom;
|
||||||
var best = null, bestDist = threshold;
|
var best = null, bestDist = threshold;
|
||||||
edges.forEach(function (e) {
|
edges.forEach(function (e) {
|
||||||
var a = nodeById[e.from], b = nodeById[e.to];
|
var a = nodeById[e.from], b = nodeById[e.to];
|
||||||
if (!a || !b) return;
|
if (!a || !b) return;
|
||||||
// point-to-segment distance
|
|
||||||
var dx = b.x - a.x, dy = b.y - a.y;
|
var dx = b.x - a.x, dy = b.y - a.y;
|
||||||
var lenSq = dx * dx + dy * dy;
|
var lenSq = dx * dx + dy * dy;
|
||||||
if (lenSq === 0) return;
|
if (lenSq === 0) return;
|
||||||
@@ -487,6 +1091,7 @@
|
|||||||
|
|
||||||
function handleStart(e) {
|
function handleStart(e) {
|
||||||
if (e.touches) e.preventDefault();
|
if (e.touches) e.preventDefault();
|
||||||
|
if (e.touches && e.touches.length >= 2) return; // pinch handled separately
|
||||||
notifyInteraction();
|
notifyInteraction();
|
||||||
var p = coordFromEvent(e);
|
var p = coordFromEvent(e);
|
||||||
ptrStartX = ptrEndX = p.x;
|
ptrStartX = ptrEndX = p.x;
|
||||||
@@ -524,23 +1129,30 @@
|
|||||||
if (!moved && dt < 300) {
|
if (!moved && dt < 300) {
|
||||||
var hit = nodeAt(ptrStartX, ptrStartY);
|
var hit = nodeAt(ptrStartX, ptrStartY);
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
// double-tap detection — toggle expand/collapse
|
// double-tap detection — only expand/collapse, no panel
|
||||||
if (hit && hit === lastTapNode && (now - lastTapTime) < 400) {
|
if (hit && hit === lastTapNode && (now - lastTapTime) < 400) {
|
||||||
lastTapNode = null; lastTapTime = 0;
|
lastTapNode = null; lastTapTime = 0;
|
||||||
|
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
|
||||||
toggleExpandCollapse(hit);
|
toggleExpandCollapse(hit);
|
||||||
} else if (hit) {
|
} else if (hit) {
|
||||||
lastTapNode = hit; lastTapTime = now;
|
lastTapNode = hit; lastTapTime = now;
|
||||||
if (hit === selected) hidePopup();
|
// Delay panel open to distinguish from double-tap
|
||||||
else showPopup(hit);
|
var tappedNode = hit;
|
||||||
|
if (singleTapTimer) clearTimeout(singleTapTimer);
|
||||||
|
singleTapTimer = setTimeout(function () {
|
||||||
|
singleTapTimer = null;
|
||||||
|
if (tappedNode === selected && panelOpen) hidePanel();
|
||||||
|
else showNodePanel(tappedNode);
|
||||||
|
}, 350);
|
||||||
} else {
|
} else {
|
||||||
lastTapNode = null; lastTapTime = 0;
|
lastTapNode = null; lastTapTime = 0;
|
||||||
// no node hit — check if an edge was tapped
|
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
|
||||||
var hitEdge = edgeAt(ptrStartX, ptrStartY);
|
var hitEdge = edgeAt(ptrStartX, ptrStartY);
|
||||||
if (hitEdge) {
|
if (hitEdge) {
|
||||||
if (hitEdge === selectedEdge) hidePopup();
|
if (hitEdge === selectedEdge && panelOpen) hidePanel();
|
||||||
else showEdgePopup(hitEdge);
|
else showEdgePanel(hitEdge);
|
||||||
} else {
|
} else {
|
||||||
hidePopup();
|
if (panelOpen) hidePanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,10 +1178,6 @@
|
|||||||
canvas.addEventListener('mousemove', handleMove);
|
canvas.addEventListener('mousemove', handleMove);
|
||||||
canvas.addEventListener('mouseup', handleEnd);
|
canvas.addEventListener('mouseup', handleEnd);
|
||||||
|
|
||||||
// dismiss popup when tapping/clicking it
|
|
||||||
popup.addEventListener('touchend', function (e) { e.stopPropagation(); hidePopup(); });
|
|
||||||
popup.addEventListener('click', function (e) { e.stopPropagation(); hidePopup(); });
|
|
||||||
|
|
||||||
// ---- zoom: scroll wheel ----
|
// ---- zoom: scroll wheel ----
|
||||||
canvas.addEventListener('wheel', function (e) {
|
canvas.addEventListener('wheel', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -577,7 +1185,6 @@
|
|||||||
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||||
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||||
var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10);
|
var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10);
|
||||||
// zoom toward mouse position
|
|
||||||
panX = mx - (mx - panX) * (newZoom / zoom);
|
panX = mx - (mx - panX) * (newZoom / zoom);
|
||||||
panY = my - (my - panY) * (newZoom / zoom);
|
panY = my - (my - panY) * (newZoom / zoom);
|
||||||
zoom = newZoom;
|
zoom = newZoom;
|
||||||
@@ -638,14 +1245,12 @@
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resize();
|
resize();
|
||||||
// WebView may not have layout yet — retry until we get real dimensions
|
|
||||||
if ((W <= 1 || H <= 1) && initRetries < 20) {
|
if ((W <= 1 || H <= 1) && initRetries < 20) {
|
||||||
initRetries++;
|
initRetries++;
|
||||||
setTimeout(init, 50);
|
setTimeout(init, 50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
placeInitial();
|
placeInitial();
|
||||||
// center the camera on the graph with the pre-computed zoom
|
|
||||||
panX = W / 2 - (W / 2) * zoom;
|
panX = W / 2 - (W / 2) * zoom;
|
||||||
panY = H / 2 - (H / 2) * zoom;
|
panY = H / 2 - (H / 2) * zoom;
|
||||||
loop();
|
loop();
|
||||||
@@ -656,7 +1261,6 @@
|
|||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use rAF to ensure the browser has laid out the canvas before we read its size
|
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
requestAnimationFrame(init);
|
requestAnimationFrame(init);
|
||||||
});
|
});
|
||||||
|
|||||||
24
landing/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
43
landing/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
8
landing/astro.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://pocketgraph.app',
|
||||||
|
integrations: [sitemap()],
|
||||||
|
});
|
||||||
4850
landing/package-lock.json
generated
Normal file
18
landing/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "pocketgraph",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
|
"astro": "^6.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
2973
landing/pnpm-lock.yaml
generated
Normal file
BIN
landing/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
landing/public/demo.mp4
Normal file
BIN
landing/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
landing/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
17
landing/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
landing/public/logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
landing/public/memgraph-logo.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
landing/public/neo4j-logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
landing/public/og.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
landing/public/screenshot1.avif
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
landing/public/screenshot2.avif
Normal file
|
After Width: | Height: | Size: 45 KiB |
21
landing/public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "PocketGraph",
|
||||||
|
"short_name": "PocketGraph",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
landing/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
landing/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
81
landing/src/components/Footer.astro
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
const APP_STORE_URL = "https://apps.apple.com/nl/app/pocketgraph/id1604368926";
|
||||||
|
const PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=nl.resoftware.pocketgraph";
|
||||||
|
---
|
||||||
|
<footer>
|
||||||
|
<div class="section-inner footer-inner">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<img src="/logo.png" alt="PocketGraph" class="footer-icon" />
|
||||||
|
<span class="footer-name">PocketGraph</span>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href={APP_STORE_URL} target="_blank" rel="noopener">App Store</a>
|
||||||
|
<a href={PLAY_STORE_URL} target="_blank" rel="noopener">Google Play</a>
|
||||||
|
<a href="https://github.com/resoftware/pocketgraph" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a href="https://resoftware.nl/contact" target="_blank" rel="noopener">Support</a>
|
||||||
|
<a href="/privacy">Privacy Policy</a>
|
||||||
|
</div>
|
||||||
|
<p class="footer-copy mono">
|
||||||
|
© {new Date().getFullYear()} Re: Software B.V. · Neo4j® is a registered trademark of Neo4j, Inc. · Memgraph® is a registered trademark of Memgraph Ltd.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
border-top: 1px solid rgba(226, 226, 226, 0.1);
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
}
|
||||||
|
.section-inner {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
.footer-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.footer-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.footer-icon {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
}
|
||||||
|
.footer-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #e8e8e8;
|
||||||
|
}
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.footer-links a {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9a9a9a;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.footer-links a:hover { color: #e2e2e2; }
|
||||||
|
.footer-copy {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #333333;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.mono { font-family: 'IBM Plex Mono', monospace; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.footer-inner { flex-direction: column; align-items: flex-start; }
|
||||||
|
.footer-links { flex-direction: column; gap: 0.6rem; }
|
||||||
|
.footer-copy { text-align: left; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
34
landing/src/components/Meta.astro
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
canonical?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title = 'PocketGraph — Neo4j & Memgraph Client',
|
||||||
|
description = 'Connect to your Neo4j or Memgraph database from iPhone, iPad, and Android. Full Cypher editor, graph visualization, and Bolt protocol — in your pocket.',
|
||||||
|
canonical,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const siteUrl = 'https://pocketgraph.app';
|
||||||
|
const ogImage = `${siteUrl}/og.png`;
|
||||||
|
const url = canonical ? `${siteUrl}${canonical}` : siteUrl;
|
||||||
|
---
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={url} />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={url} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:image" content={ogImage} />
|
||||||
|
<meta property="og:site_name" content="PocketGraph" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={ogImage} />
|
||||||
84
landing/src/components/Nav.astro
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
<nav id="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/" class="nav-logo">
|
||||||
|
<img src="/logo.png" alt="PocketGraph icon" class="nav-icon" />
|
||||||
|
<span>PocketGraph</span>
|
||||||
|
</a>
|
||||||
|
<a href="#download" class="nav-dl">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0 2rem;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.3s, border-color 0.3s;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
#nav.scrolled {
|
||||||
|
background: rgba(12, 12, 12, 0.9);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-color: rgba(226, 226, 226, 0.1);
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
max-width: 1120px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.nav-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #e8e8e8;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
.nav-icon {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.nav-dl {
|
||||||
|
font-family: 'Syne', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #e2e2e2;
|
||||||
|
border: 1px solid #e2e2e2;
|
||||||
|
padding: 0.45rem 1.1rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-dl:hover { background: #e2e2e2; color: #0c0c0c; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#nav { padding: 0 1.25rem; height: 64px; }
|
||||||
|
.nav-icon { width: 40px; height: 40px; }
|
||||||
|
.nav-logo { font-size: 1rem; gap: 10px; margin-left: 0; }
|
||||||
|
.nav-logo span { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
const nav = document.getElementById('nav');
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
nav?.classList.toggle('scrolled', window.scrollY > 20);
|
||||||
|
}, { passive: true });
|
||||||
|
</script>
|
||||||
1136
landing/src/pages/index.astro
Normal file
233
landing/src/pages/privacy.astro
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
---
|
||||||
|
import Nav from '../components/Nav.astro';
|
||||||
|
import Meta from '../components/Meta.astro';
|
||||||
|
---
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<Meta
|
||||||
|
title="Privacy Policy — PocketGraph"
|
||||||
|
description="Privacy policy for PocketGraph, the Neo4j & Memgraph client for iPhone, iPad, and Android."
|
||||||
|
canonical="/privacy"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;1,300&display=swap" rel="stylesheet" />
|
||||||
|
<script src="https://rybbit.resoftware.work/api/script.js" data-site-id="d2f58ef3c722" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<Nav />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="content">
|
||||||
|
<p class="mono label">// legal</p>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="meta mono">Re: Software B.V. · Last updated: March 2026</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p>PocketGraph is a graph database client for iPhone, iPad, and Android. We take your privacy seriously. This policy explains what data we collect, why, and how it is used.</p>
|
||||||
|
<p>The short version: <strong>PocketGraph does not collect, store, or transmit any of your database credentials or query data.</strong> All connections are made directly from your device to your database.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Data We Do Not Collect</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Database hostnames, ports, usernames, or passwords</li>
|
||||||
|
<li>Cypher queries you write or execute</li>
|
||||||
|
<li>Query results or any data stored in your database</li>
|
||||||
|
<li>Graph visualizations or schemas</li>
|
||||||
|
</ul>
|
||||||
|
<p>This data never leaves your device and is never sent to Re: Software or any third party.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Data Stored on Your Device</h2>
|
||||||
|
<p>PocketGraph stores the following locally on your device only:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Connection profiles (host, port, username) — stored in the iOS Keychain or Android Keystore</li>
|
||||||
|
<li>Query history — stored locally, never synced externally</li>
|
||||||
|
<li>App preferences and settings</li>
|
||||||
|
</ul>
|
||||||
|
<p>This data is protected by system-level encryption (iOS Keychain / Android Keystore) and is not accessible to Re: Software.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Analytics</h2>
|
||||||
|
<p>This website (pocketgraph.app) uses <a href="https://rybbit.io" target="_blank" rel="noopener">Rybbit</a>, a privacy-friendly analytics tool, to collect anonymised page view data. No cookies are set, no personal data is collected, and no cross-site tracking occurs.</p>
|
||||||
|
<p>The PocketGraph app itself does not include any analytics SDK.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Network Connections</h2>
|
||||||
|
<p>PocketGraph connects directly from your device to your Neo4j or Memgraph database using the Bolt protocol over SSL/TLS. No traffic is routed through Re: Software servers. Your database credentials are used solely to authenticate with your own database.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Purchases</h2>
|
||||||
|
<p>PocketGraph is sold as a one-time purchase through the Apple App Store and Google Play Store. All payment processing is handled by Apple or Google respectively. Re: Software does not receive or store any payment or billing information.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Children</h2>
|
||||||
|
<p>PocketGraph is not directed at children under 13 and we do not knowingly collect data from children.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Changes to This Policy</h2>
|
||||||
|
<p>If this policy changes materially, we will update the date at the top of this page. Continued use of the app constitutes acceptance of the updated policy.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>Questions about this policy? Reach us at <a href="https://resoftware.nl/contact" target="_blank" rel="noopener">resoftware.nl/contact</a>.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="section-inner footer-inner">
|
||||||
|
<a href="/" class="footer-back mono">← Back to PocketGraph</a>
|
||||||
|
<p class="footer-copy mono">© {new Date().getFullYear()} Re: Software B.V.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0c0c0c;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--muted: #9a9a9a;
|
||||||
|
--border: rgba(226, 226, 226, 0.1);
|
||||||
|
--font-sans: 'Syne', sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAIN */
|
||||||
|
main {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 5rem 2rem 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-bottom: 2.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
section:last-of-type { border-bottom: none; }
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #aaaaaa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
strong { color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.5rem 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
ul li {
|
||||||
|
color: #aaaaaa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
ul li::before {
|
||||||
|
content: '–';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text);
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
a:hover { color: #fff; }
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* FOOTER */
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
.section-inner { max-width: 1120px; margin: 0 auto; padding: 0 2rem; }
|
||||||
|
.footer-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.footer-back {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.footer-back:hover { color: var(--text); }
|
||||||
|
.footer-copy {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #333333;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
landing/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||