Compare commits
35 Commits
redesign-a
...
927e7627a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
927e7627a6 | ||
|
|
14d755f8bb | ||
|
|
fb504a88c2 | ||
|
|
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
|
||||
71
.github/workflows/deploy-playstore.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
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
|
||||
changesNotSentForReview: true
|
||||
whatsNewDirectory: Xamarin.Neo4j/distribution/whatsnew
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Android.Content;
|
||||
using Android.Content.Res;
|
||||
using Android.Graphics;
|
||||
using Android.Graphics.Drawables;
|
||||
using Android.Text;
|
||||
using Android.Text.Style;
|
||||
using Android.Views;
|
||||
using Android.Views.InputMethods;
|
||||
using Android.Widget;
|
||||
using AndroidX.Core.View;
|
||||
using Microsoft.Maui.Handlers;
|
||||
using Microsoft.Maui.Platform;
|
||||
using Xamarin.Neo4j.Controls;
|
||||
@@ -49,6 +52,9 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
||||
"FALSE", "NULL", "TRUE"
|
||||
];
|
||||
|
||||
private LinearLayout _toolbar;
|
||||
private bool _toolbarAttached;
|
||||
|
||||
protected override void ConnectHandler(MauiAppCompatEditText platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
@@ -58,43 +64,136 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
||||
| InputTypes.TextFlagMultiLine
|
||||
| InputTypes.TextFlagNoSuggestions;
|
||||
|
||||
// Keyboard toolbar with symbol keys and Execute button
|
||||
var toolbar = BuildToolbar(platformView);
|
||||
// Build the keyboard toolbar
|
||||
_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) =>
|
||||
toolbar.Visibility = e.HasFocus ? ViewStates.Visible : ViewStates.Gone;
|
||||
{
|
||||
if (!e.HasFocus)
|
||||
_toolbar.Visibility = ViewStates.Gone;
|
||||
};
|
||||
|
||||
// Syntax highlighting
|
||||
platformView.AddTextChangedListener(new CypherTextWatcher(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)
|
||||
{
|
||||
var context = platformView.Context;
|
||||
var density = context.Resources.DisplayMetrics.Density;
|
||||
|
||||
var toolbar = new LinearLayout(context)
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Orientation = global::Android.Widget.Orientation.Horizontal,
|
||||
LayoutParameters = new LinearLayout.LayoutParams(
|
||||
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)
|
||||
{
|
||||
LayoutParameters = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)
|
||||
LayoutParameters = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)
|
||||
};
|
||||
scrollView.HorizontalScrollBarEnabled = false;
|
||||
|
||||
var keyRow = new LinearLayout(context) { Orientation = Orientation.Horizontal };
|
||||
foreach (var key in new[] { "(", ")", "[", "]", ":", "-", "->", "<-" })
|
||||
var keyRow = new LinearLayout(context)
|
||||
{
|
||||
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 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) =>
|
||||
{
|
||||
var start = Math.Max(platformView.SelectionStart, 0);
|
||||
@@ -105,12 +204,34 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
||||
}
|
||||
|
||||
scrollView.AddView(keyRow);
|
||||
toolbar.AddView(scrollView);
|
||||
pillContainer.AddView(scrollView);
|
||||
toolbar.AddView(pillContainer);
|
||||
|
||||
// Execute button
|
||||
var executeBtn = CreatePillButton(context, "Execute");
|
||||
// Spacer
|
||||
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.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) =>
|
||||
{
|
||||
if (VirtualView is QueryEditor queryEditor)
|
||||
@@ -120,33 +241,23 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
||||
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);
|
||||
|
||||
// Attach toolbar to the parent view hierarchy
|
||||
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
|
||||
{
|
||||
if (platformView.Parent is ViewGroup parent && toolbar.Parent == null)
|
||||
parent.AddView(toolbar);
|
||||
};
|
||||
|
||||
toolbar.Visibility = ViewStates.Gone;
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
|
||||
@@ -191,6 +302,15 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
||||
|
||||
// ── 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 readonly MauiAppCompatEditText _view;
|
||||
@@ -210,9 +330,50 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
|
||||
{
|
||||
if (_updating) return;
|
||||
_updating = true;
|
||||
try { HighlightSyntax(_view, _keywords); }
|
||||
try
|
||||
{
|
||||
AutoCapitalizeKeywords(_view, _keywords);
|
||||
HighlightSyntax(_view, _keywords);
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ namespace Xamarin.Neo4j.Android
|
||||
var statusBarColor = isDark
|
||||
? AColor.ParseColor("#0c0c0c")
|
||||
: AColor.ParseColor("#f5f5f5");
|
||||
#pragma warning disable CA1422
|
||||
Window.SetStatusBarColor(statusBarColor);
|
||||
#pragma warning restore CA1422
|
||||
|
||||
var controller = new WindowInsetsControllerCompat(Window, Window.DecorView);
|
||||
controller.AppearanceLightStatusBars = !isDark;
|
||||
@@ -53,21 +55,24 @@ namespace Xamarin.Neo4j.Android
|
||||
{
|
||||
base.OnWindowFocusChanged(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
|
||||
// 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;
|
||||
for (var i = 0; i < parent.ChildCount; i++)
|
||||
{
|
||||
var child = parent.GetChildAt(i);
|
||||
if (child is Toolbar toolbar)
|
||||
{
|
||||
toolbar.OverflowIcon?.SetTint(AColor.White);
|
||||
toolbar.NavigationIcon?.SetTint(AColor.White);
|
||||
}
|
||||
else if (child is ViewGroup vg)
|
||||
TintToolbarOverflow(vg);
|
||||
TintToolbarIcons(vg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0"
|
||||
android:versionCode="2"
|
||||
android:versionName="2.0.0"
|
||||
package="nl.resoftware.pocketgraph">
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="36" />
|
||||
<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.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Android.Runtime;
|
||||
using Java.Security;
|
||||
using Java.Security.Cert;
|
||||
using Javax.Net.Ssl;
|
||||
@@ -25,8 +26,9 @@ namespace Xamarin.Neo4j
|
||||
var factory = TrustManagerFactory.GetInstance(TrustManagerFactory.DefaultAlgorithm);
|
||||
factory.Init((KeyStore)null);
|
||||
_androidTrustManager = factory.GetTrustManagers()
|
||||
.OfType<IX509TrustManager>()
|
||||
.First();
|
||||
.OfType<Java.Lang.Object>()
|
||||
.Select(m => m.JavaCast<IX509TrustManager>())
|
||||
.First(m => m != null);
|
||||
}
|
||||
|
||||
public override bool ValidateServerCertificate(Uri uri, X509Certificate2 certificate, X509Chain chain,
|
||||
|
||||
@@ -22,11 +22,11 @@ namespace Xamarin.Neo4j.Android.Services
|
||||
{
|
||||
var context = global::Android.App.Application.Context;
|
||||
var info = context.PackageManager.GetPackageInfo(context.PackageName, 0);
|
||||
#pragma warning disable CS0618
|
||||
#pragma warning disable CS0618, CA1416, CA1422
|
||||
return Build.VERSION.SdkInt >= BuildVersionCodes.P
|
||||
? info.LongVersionCode.ToString()
|
||||
: info.VersionCode.ToString();
|
||||
#pragma warning restore CS0618
|
||||
#pragma warning restore CS0618, CA1416, CA1422
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<Nullable>disable</Nullable>
|
||||
<!-- Embed all assemblies in the APK so plain adb install works without Fast Deployment -->
|
||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
||||
<ApplicationDisplayVersion>2.0.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>2</ApplicationVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -32,6 +34,10 @@
|
||||
Link="Assets\logo.png" />
|
||||
<AndroidAsset Include="..\Xamarin.Neo4j.iOS\Resources\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>
|
||||
|
||||
@@ -12,6 +12,7 @@ using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Handlers;
|
||||
using Microsoft.Maui.Platform;
|
||||
@@ -22,6 +23,15 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
|
||||
{
|
||||
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 =
|
||||
[
|
||||
// Clauses
|
||||
@@ -60,14 +70,16 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
|
||||
|
||||
const float accessoryHeight = 58f;
|
||||
const float buttonWidth = 33f;
|
||||
const float executeButtonWidth = 90f;
|
||||
|
||||
const float padding = 8f;
|
||||
const float pillHeight = 38f;
|
||||
|
||||
var keys = new[]
|
||||
{
|
||||
("(", "("), (")", ")"), ("[", "["), ("]", "]"),
|
||||
(":", ":"), ("-", "-"), ("\u2192", "->"), ("\u2190", "<-")
|
||||
("{", "{"), ("}", "}"), (":", ":"), ("-", "-"),
|
||||
("\u2192", "->"), ("\u2190", "<-"), ("\"", "\""),
|
||||
("'", "'"), (".", "."), ("=", "="), ("*", "*"), ("$", "$")
|
||||
};
|
||||
|
||||
// Outer accessory — clear so system background shows through
|
||||
@@ -112,50 +124,42 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
|
||||
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(executeBtn);
|
||||
|
||||
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.TrailingAnchor.ConstraintEqualTo(executeBtn.LeadingAnchor, -padding),
|
||||
pillContainer.TrailingAnchor.ConstraintEqualTo(accessoryView.TrailingAnchor, -padding),
|
||||
pillContainer.CenterYAnchor.ConstraintEqualTo(accessoryView.CenterYAnchor),
|
||||
pillContainer.HeightAnchor.ConstraintEqualTo(pillHeight),
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
HighlightWords(platformView, _keyWords);
|
||||
platformView.Changed += (s, e) =>
|
||||
{
|
||||
AutoCapitalizeKeywords(platformView, _keyWords);
|
||||
HighlightWords(platformView, _keyWords);
|
||||
};
|
||||
}
|
||||
|
||||
private static void HighlightWords(UITextView platformView, IEnumerable<string> wordsToHighlight)
|
||||
{
|
||||
var text = PreprocessText(platformView.Text ?? string.Empty);
|
||||
var attributedText = new NSMutableAttributedString(text);
|
||||
attributedText.AddAttribute(UIStringAttributeKey.ForegroundColor,
|
||||
UIColor.Label,
|
||||
new NSRange(0, text.Length));
|
||||
|
||||
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)
|
||||
{
|
||||
text = text.Replace("\u2018", "'");
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</imageView>
|
||||
</subviews>
|
||||
<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>
|
||||
<constraint firstItem="198" firstAttribute="centerX" secondItem="1038" secondAttribute="centerX" id="201"/>
|
||||
<constraint firstItem="1038" firstAttribute="centerY" secondItem="198" secondAttribute="centerY" id="203"/>
|
||||
|
||||
@@ -21,7 +21,9 @@ namespace Xamarin.Neo4j
|
||||
SetTheme(Current.RequestedTheme);
|
||||
RequestedThemeChanged += (s, e) => SetTheme(e.RequestedTheme);
|
||||
|
||||
#pragma warning disable CS0618
|
||||
MainPage = new NavigationPage(new ConnectionsPage());
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// SetTheme ran before MainPage was assigned, so apply bar colours now.
|
||||
ApplyNavBarColors();
|
||||
@@ -47,9 +49,13 @@ namespace Xamarin.Neo4j
|
||||
// theme changes while the app is backgrounded are picked up on return.
|
||||
public void ApplyNavBarColors()
|
||||
{
|
||||
if (MainPage is NavigationPage navPage)
|
||||
if (Windows.Count > 0 && Windows[0].Page is NavigationPage navPage)
|
||||
{
|
||||
navPage.BarBackgroundColor = Color.FromArgb("#31333b");
|
||||
if (Resources.TryGetValue("NavigationBarColor", out var navColor) && navColor is Color color)
|
||||
navPage.BarBackgroundColor = color;
|
||||
else
|
||||
navPage.BarBackgroundColor = Color.FromArgb("#31333b");
|
||||
|
||||
navPage.BarTextColor = Colors.White;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
|
||||
x:DataType="models:Neo4jConnectionString"
|
||||
x:Class="Xamarin.Neo4j.Controls.ConnectionCell">
|
||||
<Grid Padding="15, 10">
|
||||
<StackLayout Spacing="2">
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Maui.Controls.Xaml;
|
||||
namespace Xamarin.Neo4j.Controls
|
||||
{
|
||||
[XamlCompilation(XamlCompilationOptions.Compile)]
|
||||
#pragma warning disable CS0618
|
||||
public partial class ConnectionCell : ViewCell
|
||||
{
|
||||
public ConnectionCell()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="Xamarin.Neo4j.Controls.LicenseCell">
|
||||
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
|
||||
x:DataType="models:License"
|
||||
x:Class="Xamarin.Neo4j.Controls.LicenseCell">
|
||||
<StackLayout Padding="15, 15, 15, 0">
|
||||
<Label FontAttributes="Bold" TextColor="{DynamicResource Accent}" Text="{Binding Name}">
|
||||
<Label.GestureRecognizers>
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Maui.Controls.Xaml;
|
||||
namespace Xamarin.Neo4j.Controls
|
||||
{
|
||||
[XamlCompilation(XamlCompilationOptions.Compile)]
|
||||
#pragma warning disable CS0618
|
||||
public partial class LicenseCell : ViewCell
|
||||
{
|
||||
public LicenseCell()
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
|
||||
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
|
||||
x:DataType="models:QueryResult"
|
||||
x:Class="Xamarin.Neo4j.Controls.QueryResultView"
|
||||
x:Name="self">
|
||||
<Grid VerticalOptions="Start">
|
||||
|
||||
<!-- Graph WebView -->
|
||||
<WebView x:Name="graphView"
|
||||
<WebView x:Name="graphView" x:DataType="{x:Null}"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
IsVisible="{Binding CanDisplayGraph}"
|
||||
HeightRequest="{Binding GraphViewHeight, Source={x:Reference self}}" />
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Reflection;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Xaml;
|
||||
using Microsoft.Maui.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui;
|
||||
using Xamarin.Neo4j.Managers;
|
||||
@@ -42,8 +43,28 @@ namespace Xamarin.Neo4j.Controls
|
||||
graphView.Source = new HtmlWebViewSource { Html = QueryResult?.NeovisHtml };
|
||||
}
|
||||
|
||||
private double _originalHeight;
|
||||
|
||||
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;
|
||||
|
||||
e.Cancel = true;
|
||||
@@ -110,6 +131,13 @@ namespace Xamarin.Neo4j.Controls
|
||||
html = html.Replace("{{edges}}", edgesJson);
|
||||
html = html.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -666,7 +666,7 @@ namespace Xamarin.Neo4j.Fonts
|
||||
public const string Divide = "\uf529";
|
||||
public const string DoorClosed = "\uf52a";
|
||||
public const string DoorOpen = "\uf52b";
|
||||
public const string Equals = "\uf52c";
|
||||
public new const string Equals = "\uf52c";
|
||||
public const string Feather = "\uf52d";
|
||||
public const string Frog = "\uf52e";
|
||||
public const string GasPump = "\uf52f";
|
||||
|
||||
@@ -69,6 +69,7 @@ namespace Xamarin.Neo4j.Managers
|
||||
if (connectionStringToUpdate == null)
|
||||
return;
|
||||
|
||||
connectionStringToUpdate.Name = connectionString.Name;
|
||||
connectionStringToUpdate.Scheme = connectionString.Scheme;
|
||||
connectionStringToUpdate.Host = connectionString.Host;
|
||||
connectionStringToUpdate.Username = connectionString.Username;
|
||||
|
||||
@@ -18,5 +18,7 @@ namespace Xamarin.Neo4j.Models
|
||||
public bool Default { get; set; }
|
||||
|
||||
public string DisplayName => Name + (Default ? " 🏠" : string.Empty);
|
||||
|
||||
public override string ToString() => DisplayName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ namespace Xamarin.Neo4j.Models
|
||||
|
||||
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()
|
||||
{
|
||||
var fullHost = Scheme + Host;
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
|
||||
x:DataType="vm:AddConnectionViewModel"
|
||||
Title="Add Connection"
|
||||
x:Class="Xamarin.Neo4j.Pages.AddConnectionPage">
|
||||
<ContentPage.Content>
|
||||
<ScrollView>
|
||||
<StackLayout Padding="24" Spacing="12">
|
||||
<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.ItemsSource>
|
||||
<x:Array Type="{x:Type x:String}">
|
||||
@@ -25,17 +32,17 @@
|
||||
</StackLayout>
|
||||
|
||||
<StackLayout Spacing="4">
|
||||
<Label Text="Host:" FontAttributes="Bold" FontSize="Small" />
|
||||
<Label Text="Host" FontAttributes="Bold" FontSize="Small" />
|
||||
<Entry Text="{Binding Host}" />
|
||||
</StackLayout>
|
||||
|
||||
<StackLayout Spacing="4">
|
||||
<Label Text="Username:" FontAttributes="Bold" FontSize="Small" />
|
||||
<Label Text="Username" FontAttributes="Bold" FontSize="Small" />
|
||||
<Entry Text="{Binding Username}" Keyboard="Default" />
|
||||
</StackLayout>
|
||||
|
||||
<StackLayout Spacing="4">
|
||||
<Label Text="Password:" FontAttributes="Bold" FontSize="Small" />
|
||||
<Label Text="Password" FontAttributes="Bold" FontSize="Small" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Entry Grid.Column="0" x:Name="passwordEntry"
|
||||
Text="{Binding Password}"
|
||||
@@ -51,7 +58,7 @@
|
||||
</Grid>
|
||||
</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="Save" BackgroundColor="{DynamicResource Accent}" TextColor="{DynamicResource QueryTextBackground}" Command="{Binding Commands[Save]}" />
|
||||
</StackLayout>
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
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"
|
||||
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
|
||||
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
|
||||
x:DataType="vm:ConnectionsViewModel"
|
||||
x:Class="Xamarin.Neo4j.Pages.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>
|
||||
<ToolbarItem Text="Settings" Order="Secondary" Command="{Binding Commands[OpenSettings]}" />
|
||||
@@ -14,47 +30,126 @@ x:Class="Xamarin.Neo4j.Pages.ConnectionsPage"
|
||||
|
||||
<ContentPage.Content>
|
||||
<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>
|
||||
|
||||
<StackLayout IsVisible="{Binding IsEmpty}" VerticalOptions="Center" HorizontalOptions="Center" Spacing="12">
|
||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Server}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="48"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="No connections"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Tap + to add a connection"
|
||||
FontSize="14"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
</StackLayout>
|
||||
<!-- Scrollable connection cards -->
|
||||
<ScrollView IsVisible="{Binding HasItems}">
|
||||
<StackLayout BindableLayout.ItemsSource="{Binding ConnectionStrings}"
|
||||
Spacing="0" Padding="0,16,0,80">
|
||||
<BindableLayout.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:Neo4jConnectionString">
|
||||
<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 x:DataType="{x:Null}"
|
||||
Command="{Binding BindingContext.Commands[OpenConnection], Source={x:Reference connectionsPage}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
</Grid.GestureRecognizers>
|
||||
|
||||
<!-- FAB -->
|
||||
<Button Text="{x:Static fonts:FontAwesomeSolid.Plus}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="20"
|
||||
WidthRequest="56" HeightRequest="56"
|
||||
CornerRadius="28"
|
||||
BackgroundColor="{DynamicResource Accent}"
|
||||
TextColor="White"
|
||||
VerticalOptions="End" HorizontalOptions="End"
|
||||
Margin="0,0,16,16"
|
||||
Command="{Binding Commands[AddConnection]}" />
|
||||
<!-- 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 x:DataType="{x:Null}" 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 x:DataType="{x:Null}" 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">
|
||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Server}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="48"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="No connections"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Tap + to add a connection"
|
||||
FontSize="14"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
</StackLayout>
|
||||
|
||||
<!-- FAB -->
|
||||
<Button Text="{x:Static fonts:FontAwesomeSolid.Plus}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="20"
|
||||
WidthRequest="56" HeightRequest="56"
|
||||
CornerRadius="28"
|
||||
BackgroundColor="{DynamicResource FabBackgroundColor}"
|
||||
TextColor="{DynamicResource FabTextColor}"
|
||||
VerticalOptions="End" HorizontalOptions="End"
|
||||
Margin="0,0,16,16"
|
||||
Command="{Binding Commands[AddConnection]}" />
|
||||
</Grid>
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -42,9 +42,7 @@ namespace Xamarin.Neo4j.Pages
|
||||
ViewModel.LoadConnectionStrings();
|
||||
}
|
||||
|
||||
private void OpenSession(object sender, ItemTappedEventArgs e)
|
||||
{
|
||||
ViewModel.OpenSession((Neo4jConnectionString)e.Item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
|
||||
x:DataType="vm:GraphViewModel"
|
||||
Title="Graph"
|
||||
x:Class="Xamarin.Neo4j.Pages.GraphPage">
|
||||
<ContentPage.Content>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls"
|
||||
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
|
||||
x:DataType="vm:LicensesViewModel"
|
||||
Title="Software Licenses"
|
||||
x:Class="Xamarin.Neo4j.Pages.LicensesPage">
|
||||
<ContentPage.Content>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
|
||||
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
|
||||
x:DataType="vm:QueriesViewModel"
|
||||
Title="Queries"
|
||||
x:Name="queriesPage"
|
||||
x:Class="Xamarin.Neo4j.Pages.QueriesPage">
|
||||
@@ -17,10 +20,10 @@
|
||||
<Grid>
|
||||
<ListView ItemsSource="{Binding Queries}" ItemTapped="StartSessionWithQuery" IsVisible="{Binding HasItems}">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DataTemplate x:DataType="models:Query">
|
||||
<TextCell Text="{Binding Name}" Detail="{Binding QueryText}">
|
||||
<TextCell.ContextActions>
|
||||
<MenuItem Text="Delete" Command="{Binding BindingContext.Commands[DeleteQuery], Source={x:Reference queriesPage} }" CommandParameter="{Binding .}" IsDestructive="true" />
|
||||
<MenuItem x:DataType="{x:Null}" Text="Delete" Command="{Binding BindingContext.Commands[DeleteQuery], Source={x:Reference queriesPage} }" CommandParameter="{Binding .}" IsDestructive="true" />
|
||||
</TextCell.ContextActions>
|
||||
</TextCell>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:controls="clr-namespace:Xamarin.Neo4j.Controls;assembly=Xamarin.Neo4j"
|
||||
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
|
||||
xmlns:models="clr-namespace:Xamarin.Neo4j.Models;assembly=Xamarin.Neo4j"
|
||||
x:DataType="vm:SessionViewModel"
|
||||
x:Class="Xamarin.Neo4j.Pages.SessionPage"
|
||||
x:Name="sessionPage">
|
||||
<NavigationPage.TitleView>
|
||||
@@ -21,7 +24,7 @@
|
||||
</Label.FormattedText>
|
||||
</Label>
|
||||
|
||||
<Picker x:Name="databasePicker" ItemsSource="{Binding AvailableDatabases}" ItemDisplayBinding="{Binding DisplayName}" SelectedItem="{Binding CurrentDatabase}" IsVisible="False" />
|
||||
<Picker x:Name="databasePicker" ItemsSource="{Binding AvailableDatabases}" SelectedItem="{Binding CurrentDatabase}" IsVisible="False" />
|
||||
</StackLayout>
|
||||
</NavigationPage.TitleView>
|
||||
|
||||
@@ -30,7 +33,8 @@
|
||||
<ToolbarItem.IconImageSource>
|
||||
<FontImageSource
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20" />
|
||||
Glyph="{x:Static fonts:FontAwesomeSolid.Play}" Size="20"
|
||||
Color="#007AFF" />
|
||||
</ToolbarItem.IconImageSource>
|
||||
</ToolbarItem>
|
||||
</ContentPage.ToolbarItems>
|
||||
@@ -81,11 +85,11 @@
|
||||
IsVisible="{Binding HasSavedQueries}"
|
||||
Spacing="0">
|
||||
<BindableLayout.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DataTemplate x:DataType="models:Query">
|
||||
<Grid ColumnDefinitions="*,Auto" Padding="0,10">
|
||||
<StackLayout Grid.Column="0" Spacing="2">
|
||||
<StackLayout.GestureRecognizers>
|
||||
<TapGestureRecognizer
|
||||
<TapGestureRecognizer x:DataType="{x:Null}"
|
||||
Command="{Binding BindingContext.Commands[LoadQuery], Source={x:Reference sessionPage}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
</StackLayout.GestureRecognizers>
|
||||
@@ -96,7 +100,7 @@
|
||||
MaxLines="1"
|
||||
LineBreakMode="TailTruncation" />
|
||||
</StackLayout>
|
||||
<Button Grid.Column="1"
|
||||
<Button Grid.Column="1" x:DataType="{x:Null}"
|
||||
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="16"
|
||||
@@ -143,74 +147,74 @@
|
||||
|
||||
<StackLayout BindableLayout.ItemsSource="{Binding QueryResults}" Spacing="0">
|
||||
<BindableLayout.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="12,0,12,8"
|
||||
<DataTemplate x:DataType="models:QueryResult">
|
||||
<Border Margin="12,0,12,6"
|
||||
BackgroundColor="{DynamicResource Extreme}"
|
||||
Stroke="{DynamicResource QueryTextBorder}"
|
||||
StrokeThickness="1"
|
||||
StrokeShape="RoundRectangle 4">
|
||||
<StackLayout Spacing="0">
|
||||
<!-- Action row -->
|
||||
<Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,2">
|
||||
<Grid ColumnDefinitions="*,*,*,*,Auto" Padding="4,0" x:DataType="{x:Null}">
|
||||
<Button Grid.Column="0"
|
||||
Text="{x:Static fonts:FontAwesomeSolid.Star}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||
BackgroundColor="Transparent"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HeightRequest="40"
|
||||
HeightRequest="32"
|
||||
Command="{Binding BindingContext.Commands[SaveQuery], Source={x:Reference sessionPage}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
<Button Grid.Column="1"
|
||||
Text="{x:Static fonts:FontAwesomeSolid.Expand}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||
BackgroundColor="Transparent"
|
||||
IsEnabled="{Binding CanDisplayGraph}"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HeightRequest="40"
|
||||
HeightRequest="32"
|
||||
Command="{Binding BindingContext.Commands[OpenGraph], Source={x:Reference sessionPage}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
<Button Grid.Column="2"
|
||||
Text="{x:Static fonts:FontAwesomeSolid.Code}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||
BackgroundColor="Transparent"
|
||||
IsEnabled="{Binding Success}"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HeightRequest="40"
|
||||
HeightRequest="32"
|
||||
Command="{Binding BindingContext.Commands[OpenTable], Source={x:Reference sessionPage}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
<Button Grid.Column="3"
|
||||
Text="{x:Static fonts:FontAwesomeSolid.TrashAlt}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="15"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="13"
|
||||
BackgroundColor="Transparent"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HeightRequest="40"
|
||||
HeightRequest="32"
|
||||
Command="{Binding BindingContext.Commands[DeleteResult], Source={x:Reference sessionPage}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
<Button Grid.Column="4"
|
||||
Text="{x:Static fonts:FontAwesomeSolid.ChevronUp}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="12"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}" FontSize="10"
|
||||
BackgroundColor="Transparent"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
WidthRequest="40" HeightRequest="40"
|
||||
WidthRequest="32" HeightRequest="32"
|
||||
Clicked="ToggleResultCollapse" />
|
||||
</Grid>
|
||||
|
||||
<!-- Query label -->
|
||||
<Label Text="{Binding DisplayQuery}"
|
||||
Padding="12,0,12,8"
|
||||
FontSize="12"
|
||||
Padding="12,0,12,4"
|
||||
FontSize="11"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
LineBreakMode="TailTruncation"
|
||||
MaxLines="2">
|
||||
MaxLines="1">
|
||||
<Label.GestureRecognizers>
|
||||
<TapGestureRecognizer
|
||||
<TapGestureRecognizer x:DataType="{x:Null}"
|
||||
Command="{Binding BindingContext.Commands[LoadResultQuery], Source={x:Reference sessionPage}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
</Label.GestureRecognizers>
|
||||
</Label>
|
||||
|
||||
<!-- Result content -->
|
||||
<controls:QueryResultView
|
||||
<controls:QueryResultView x:DataType="{x:Null}"
|
||||
GraphViewHeight="{Binding BindingContext.GraphViewHeight, Source={x:Reference sessionPage}}" />
|
||||
</StackLayout>
|
||||
</Border>
|
||||
@@ -219,27 +223,28 @@
|
||||
</StackLayout>
|
||||
</StackLayout>
|
||||
|
||||
<!-- Empty hint (no results yet) -->
|
||||
<StackLayout IsVisible="{Binding IsEmpty}" HorizontalOptions="Center" Spacing="12" Padding="32,24">
|
||||
<Label Text="{x:Static fonts:FontAwesomeSolid.Database}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="48"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="No results yet"
|
||||
FontSize="18" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Write a query and tap play, or load a saved query above"
|
||||
FontSize="14"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalTextAlignment="Center"
|
||||
HorizontalOptions="Center" />
|
||||
</StackLayout>
|
||||
|
||||
</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}"
|
||||
FontFamily="{StaticResource FontAwesomeSolid}"
|
||||
FontSize="48"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="No results yet"
|
||||
FontSize="18" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Write a query and tap play, or load a saved query above"
|
||||
FontSize="14"
|
||||
TextColor="{DynamicResource SecondaryTextColor}"
|
||||
HorizontalTextAlignment="Center"
|
||||
HorizontalOptions="Center" />
|
||||
</StackLayout>
|
||||
|
||||
</Grid>
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:fonts="clr-namespace:Xamarin.Neo4j.Fonts;assembly=Xamarin.Neo4j"
|
||||
xmlns:vm="clr-namespace:Xamarin.Neo4j.ViewModels;assembly=Xamarin.Neo4j"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Title="Settings"
|
||||
x:Class="Xamarin.Neo4j.Pages.SettingsPage">
|
||||
<ContentPage.IconImageSource>
|
||||
@@ -13,157 +15,188 @@
|
||||
</ContentPage.IconImageSource>
|
||||
|
||||
<ContentPage.Content>
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
|
||||
<ScrollView Grid.Row="0">
|
||||
<StackLayout Padding="16" Spacing="24">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Data -->
|
||||
<StackLayout Spacing="8">
|
||||
<Label Text="DATA"
|
||||
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[ClearConnections]}" />
|
||||
</Grid.GestureRecognizers>
|
||||
<Label Grid.Column="0" Text="Clear Saved Connections"
|
||||
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[ClearQueries]}" />
|
||||
</Grid.GestureRecognizers>
|
||||
<Label Grid.Column="0" Text="Clear Saved Queries"
|
||||
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 -->
|
||||
<StackLayout Spacing="8">
|
||||
<Label Text="ABOUT"
|
||||
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">
|
||||
<Label Grid.Column="0" Text="{Binding VersionLabel}"
|
||||
TextColor="{DynamicResource PrimaryTextColor}"
|
||||
VerticalOptions="Center" />
|
||||
<Label Grid.Column="1"
|
||||
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[OpenLicensesPage]}" />
|
||||
</Grid.GestureRecognizers>
|
||||
<Label Grid.Column="0" Text="Software Licenses"
|
||||
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>
|
||||
<ScrollView>
|
||||
<StackLayout Padding="16" Spacing="24">
|
||||
|
||||
<!-- Editor -->
|
||||
<StackLayout Spacing="8">
|
||||
<Label Text="EDITOR"
|
||||
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,10">
|
||||
<Label Grid.Column="0" Text="Auto-capitalize keywords"
|
||||
TextColor="{DynamicResource PrimaryTextColor}"
|
||||
VerticalOptions="Center" />
|
||||
<Switch Grid.Column="1" OnColor="#007AFF"
|
||||
IsToggled="{Binding AutoCapitalize}"
|
||||
VerticalOptions="Center" />
|
||||
</Grid>
|
||||
<BoxView HeightRequest="1" BackgroundColor="{DynamicResource QueryTextBorder}" Margin="16,0" />
|
||||
<Grid ColumnDefinitions="*,Auto" Padding="16,10">
|
||||
<Label Grid.Column="0" Text="Auto-zoom on node tap"
|
||||
TextColor="{DynamicResource PrimaryTextColor}"
|
||||
VerticalOptions="Center" />
|
||||
<Switch Grid.Column="1" OnColor="#007AFF"
|
||||
IsToggled="{Binding AutoZoom}"
|
||||
VerticalOptions="Center" />
|
||||
</Grid>
|
||||
</StackLayout>
|
||||
</Border>
|
||||
</StackLayout>
|
||||
|
||||
<!-- Data -->
|
||||
<StackLayout Spacing="8">
|
||||
<Label Text="DATA"
|
||||
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[ClearConnections]}" />
|
||||
</Grid.GestureRecognizers>
|
||||
<Label Grid.Column="0" Text="Clear Saved Connections"
|
||||
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[ClearQueries]}" />
|
||||
</Grid.GestureRecognizers>
|
||||
<Label Grid.Column="0" Text="Clear Saved Queries"
|
||||
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>
|
||||
|
||||
<!-- 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 -->
|
||||
<StackLayout Spacing="8">
|
||||
<Label Text="ABOUT"
|
||||
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">
|
||||
<Label Grid.Column="0" Text="{Binding VersionLabel}"
|
||||
TextColor="{DynamicResource PrimaryTextColor}"
|
||||
VerticalOptions="Center" />
|
||||
<Label Grid.Column="1"
|
||||
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[OpenLicensesPage]}" />
|
||||
</Grid.GestureRecognizers>
|
||||
<Label Grid.Column="0" Text="Software Licenses"
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</ScrollView>
|
||||
</ContentPage.Content>
|
||||
</ContentPage>
|
||||
|
||||
@@ -76,12 +76,18 @@ namespace Xamarin.Neo4j.Pages
|
||||
|
||||
var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
|
||||
html = html.Replace("{{json}}", json);
|
||||
html = html.Replace("{{backgroundColor}}", isDark ? "#1e1e1e" : "#ffffff");
|
||||
html = html.Replace("{{textColor}}", isDark ? "#d4d4d4" : "#1a1a1a");
|
||||
html = html.Replace("{{toolbarBg}}", isDark ? "#252526" : "#f5f5f5");
|
||||
html = html.Replace("{{inputBg}}", isDark ? "#3c3c3c" : "#ffffff");
|
||||
html = html.Replace("{{borderColor}}", isDark ? "#454545" : "#cccccc");
|
||||
html = html.Replace("{{mutedColor}}", isDark ? "#808080" : "#8a8a8a");
|
||||
html = html.Replace("{{backgroundColor}}", isDark ? "#0c0c0c" : "#f5f5f5");
|
||||
html = html.Replace("{{textColor}}", isDark ? "#e2e2e2" : "#0c0c0c");
|
||||
html = html.Replace("{{toolbarBg}}", isDark ? "#141414" : "#ffffff");
|
||||
html = html.Replace("{{inputBg}}", isDark ? "#1a1a1a" : "#ffffff");
|
||||
html = html.Replace("{{borderColor}}", isDark ? "#2a2a2a" : "#e0e0e0");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<Color x:Key="QueryTextBorder">#2a2a2a</Color>
|
||||
<Color x:Key="QueryActionBar">#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">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
|
||||
@@ -28,7 +31,7 @@
|
||||
</Style>
|
||||
|
||||
<Style TargetType="NavigationPage">
|
||||
<Setter Property="BarBackgroundColor" Value="#141414"/>
|
||||
<Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
|
||||
<Setter Property="BarTextColor" Value="#e2e2e2" />
|
||||
</Style>
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<Color x:Key="QueryTextBorder">#e0e0e0</Color>
|
||||
<Color x:Key="QueryActionBar">#f5f5f5</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">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource PageBackground}" />
|
||||
@@ -37,7 +40,7 @@
|
||||
</Style>
|
||||
|
||||
<Style TargetType="NavigationPage">
|
||||
<Setter Property="BarBackgroundColor" Value="#0c0c0c"/>
|
||||
<Setter Property="BarBackgroundColor" Value="{StaticResource NavigationBarColor}"/>
|
||||
<Setter Property="BarTextColor" Value="#e2e2e2" />
|
||||
</Style>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
private Guid? _id;
|
||||
|
||||
private string _scheme, _host, _username, _password;
|
||||
private string _name, _scheme, _host, _username, _password;
|
||||
|
||||
private readonly Neo4jService _neo4jService;
|
||||
|
||||
@@ -48,27 +48,20 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
var (_, message) = await _neo4jService.EstablishConnection(connectionString);
|
||||
|
||||
await Application.Current.MainPage.DisplayAlert("", message, "OK");
|
||||
await Application.Current.Windows[0].Page.DisplayAlertAsync("", message, "OK");
|
||||
}));
|
||||
|
||||
Commands.Add("Save", new Command(async () =>
|
||||
{
|
||||
var connectionString = BuildConnectionString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectionString.Name))
|
||||
connectionString.Name = connectionString.Host;
|
||||
|
||||
if (_id.HasValue)
|
||||
await ConnectionStringManager.UpdateConnectionString(_id.Value, connectionString);
|
||||
|
||||
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 Navigation.PopAsync();
|
||||
}));
|
||||
@@ -83,7 +76,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
await Navigation.PushAsync(new SessionPage(connectionString));
|
||||
|
||||
else
|
||||
await Application.Current.MainPage.DisplayAlert("", message, "OK");
|
||||
await Application.Current.Windows[0].Page.DisplayAlertAsync("", message, "OK");
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -91,6 +84,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
_id = neo4JConnectionString.Id;
|
||||
|
||||
Name = neo4JConnectionString.Name;
|
||||
Scheme = neo4JConnectionString.Scheme;
|
||||
Host = neo4JConnectionString.Host;
|
||||
Username = neo4JConnectionString.Username;
|
||||
@@ -114,6 +108,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
return new Neo4jConnectionString
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = Name,
|
||||
Scheme = Scheme,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
@@ -123,6 +118,16 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
#region Bindable Properties
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
_name = value;
|
||||
OnPropertyChanged(nameof(Name));
|
||||
}
|
||||
}
|
||||
|
||||
public string Scheme
|
||||
{
|
||||
get => _scheme;
|
||||
|
||||
@@ -50,6 +50,12 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
LoadConnectionStrings();
|
||||
}));
|
||||
|
||||
Commands.Add("OpenConnection", new Command((o) =>
|
||||
{
|
||||
if (o is Neo4jConnectionString cs)
|
||||
OpenSession(cs);
|
||||
}));
|
||||
|
||||
Commands.Add("OpenSettings", new Command(async () =>
|
||||
{
|
||||
await Navigation.PushAsync(new SettingsPage());
|
||||
@@ -87,6 +93,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
OnPropertyChanged(nameof(ConnectionStrings));
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
OnPropertyChanged(nameof(HasItems));
|
||||
OnPropertyChanged(nameof(ConnectionCountLabel));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +101,20 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
{
|
||||
if (ConnectionStringManager.ActiveConnectionString == null)
|
||||
{
|
||||
await Application.Current.MainPage.DisplayAlert("", "Please select a connection before starting a session.", "OK");
|
||||
await Application.Current.Windows[0].Page.DisplayAlertAsync("", "Please select a connection before starting a session.", "OK");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
if (!(o is Query query))
|
||||
return;
|
||||
|
||||
var confirmed = await Application.Current.MainPage.DisplayAlert(
|
||||
var confirmed = await Application.Current.Windows[0].Page.DisplayAlertAsync(
|
||||
"Delete Query",
|
||||
$"Delete \"{query.Name}\"?",
|
||||
"Delete", "Cancel");
|
||||
@@ -115,7 +115,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
Commands.Add("SaveQuery", new Command(async (o) =>
|
||||
{
|
||||
if (!(o is QueryResult result)) return;
|
||||
var name = await Application.Current.MainPage.DisplayPromptAsync(
|
||||
var name = await Application.Current.Windows[0].Page.DisplayPromptAsync(
|
||||
"Save Query", "What would you like to call this query?");
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
@@ -163,7 +163,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
if (!isConnected)
|
||||
{
|
||||
await Application.Current.MainPage.DisplayAlert("", message, "OK");
|
||||
await Application.Current.Windows[0].Page.DisplayAlertAsync("", message, "OK");
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -276,7 +276,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
get
|
||||
{
|
||||
var screenHeight = _screenSizeService.GetScreenHeight();
|
||||
return Math.Max(200, screenHeight - 300);
|
||||
return Math.Max(120, (screenHeight - 300) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
if (!Email.Default.IsComposeSupported)
|
||||
{
|
||||
await Application.Current.MainPage.DisplayAlert(
|
||||
await Application.Current.Windows[0].Page.DisplayAlertAsync(
|
||||
"No Email App", "No email client is configured on this device.", "OK");
|
||||
return;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
Commands.Add("ClearConnections", new Command(async () =>
|
||||
{
|
||||
var clear = await Application.Current.MainPage.DisplayAlert(
|
||||
var clear = await Application.Current.Windows[0].Page.DisplayAlertAsync(
|
||||
"Clear Connections",
|
||||
"Are you sure you want to remove all saved connections?",
|
||||
"Clear", "Cancel");
|
||||
@@ -109,7 +109,7 @@ namespace Xamarin.Neo4j.ViewModels
|
||||
|
||||
Commands.Add("ClearQueries", new Command(async () =>
|
||||
{
|
||||
var clear = await Application.Current.MainPage.DisplayAlert(
|
||||
var clear = await Application.Current.Windows[0].Page.DisplayAlertAsync(
|
||||
"Clear Saved Queries",
|
||||
"Are you sure you want to remove all saved queries?",
|
||||
"Clear", "Cancel");
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<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}
|
||||
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 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}
|
||||
@@ -14,8 +14,8 @@ body{font-family:'Courier New',monospace;font-size:13px;background:var(--bg);col
|
||||
.tbtn:active{opacity:.7}
|
||||
#status{font-size:11px;color:var(--muted);padding:0 2px;min-height:15px}
|
||||
#output{padding:10px}
|
||||
.s{color:#4CAF50}.n{color:#64B5F6}.b{color:#FFB74D}.nl{color:#90A4AE}.k{color:#EF9A9A}.e{color:#EF5350}
|
||||
.br{color:var(--fg);opacity:.7}.cm{color:var(--fg);opacity:.4}
|
||||
.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:.85}.cm{color:var(--fg);opacity:.5}
|
||||
/* Block-based tree nodes */
|
||||
.node,.knode{display:block}
|
||||
.nhd{display:block;cursor:default}
|
||||
@@ -154,13 +154,19 @@ function count(v){
|
||||
|
||||
function setTheme(isDark){
|
||||
const r=document.documentElement.style;
|
||||
r.setProperty('--bg',isDark?'#1e1e1e':'#ffffff');
|
||||
r.setProperty('--fg',isDark?'#d4d4d4':'#1a1a1a');
|
||||
r.setProperty('--toolbar',isDark?'#252526':'#f5f5f5');
|
||||
r.setProperty('--input',isDark?'#3c3c3c':'#ffffff');
|
||||
r.setProperty('--border',isDark?'#454545':'#cccccc');
|
||||
r.setProperty('--muted',isDark?'#808080':'#8a8a8a');
|
||||
document.body.style.background=isDark?'#1e1e1e':'#ffffff';
|
||||
r.setProperty('--bg',isDark?'#0c0c0c':'#f5f5f5');
|
||||
r.setProperty('--fg',isDark?'#e2e2e2':'#0c0c0c');
|
||||
r.setProperty('--toolbar',isDark?'#141414':'#ffffff');
|
||||
r.setProperty('--input',isDark?'#1a1a1a':'#ffffff');
|
||||
r.setProperty('--border',isDark?'#2a2a2a':'#e0e0e0');
|
||||
r.setProperty('--muted',isDark?'#868686':'#868a92');
|
||||
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()});
|
||||
|
||||
@@ -2,42 +2,281 @@
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<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>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; }
|
||||
canvas { display: block; position: absolute; top: 0; left: 0; }
|
||||
|
||||
#popup {
|
||||
/* ── Slide-up detail panel ── */
|
||||
#panel-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
background: rgba(20, 20, 30, 0.95);
|
||||
color: #fff;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 55%;
|
||||
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;
|
||||
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-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-height: 40%;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
#popup .lbl {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: #7EC8E3;
|
||||
#toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
#popup .prop { color: #ccc; }
|
||||
#popup .prop b { color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
(function () {
|
||||
@@ -51,7 +290,12 @@
|
||||
|
||||
var canvas = document.getElementById('c');
|
||||
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;
|
||||
|
||||
// ---- node/edge state ----
|
||||
@@ -134,8 +378,6 @@
|
||||
edges.forEach(function (e) {
|
||||
a = nodeById[e.from]; b = nodeById[e.to];
|
||||
if (!a || !b) return;
|
||||
// leaf-to-hub: short spring (leaf stays close as petal)
|
||||
// hub-to-hub: long spring (clusters separate)
|
||||
var len;
|
||||
if (a.isHub && b.isHub) {
|
||||
len = HUB_SPRING;
|
||||
@@ -190,6 +432,35 @@
|
||||
// ---- camera (pan + zoom, initial zoom based on node count) ----
|
||||
var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10));
|
||||
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
|
||||
function toWorld(sx, sy) {
|
||||
@@ -257,7 +528,7 @@
|
||||
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.font = 'bold 11px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
@@ -267,57 +538,396 @@
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- popup ----
|
||||
function buildPropsHtml(title) {
|
||||
var html = '';
|
||||
if (title) {
|
||||
title.split(', ').forEach(function (pair) {
|
||||
var idx = pair.indexOf(': ');
|
||||
if (idx > -1) {
|
||||
html += '<div class="prop"><b>' + escHtml(pair.slice(0, idx)) + ':</b> ' + escHtml(pair.slice(idx + 2)) + '</div>';
|
||||
} else {
|
||||
html += '<div class="prop">' + escHtml(pair) + '</div>';
|
||||
}
|
||||
// ---- panel ----
|
||||
var panelOpen = false;
|
||||
var panelViewState = 'props'; // 'props', 'json', 'relations'
|
||||
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) {
|
||||
var idx = pair.indexOf(': ');
|
||||
if (idx > -1) {
|
||||
props.push({ key: pair.slice(0, idx), value: pair.slice(idx + 2) });
|
||||
} else if (pair.trim()) {
|
||||
props.push({ key: '', value: pair });
|
||||
}
|
||||
});
|
||||
return props;
|
||||
}
|
||||
|
||||
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 {
|
||||
html += '<div class="prop">No properties</div>';
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
selectedEdge = null;
|
||||
var html = '<div class="lbl">' + escHtml(n.label) + '</div>';
|
||||
html += buildPropsHtml(n.title);
|
||||
popup.innerHTML = html;
|
||||
popup.style.display = 'block';
|
||||
panelCurrentItem = n;
|
||||
panelCurrentType = 'node';
|
||||
panelViewState = 'props';
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
function showEdgePopup(e) {
|
||||
function showEdgePanel(e) {
|
||||
selectedEdge = e;
|
||||
selected = null;
|
||||
var html = '<div class="lbl" style="color:#E8A838">' + escHtml(e.label || 'Relationship') + '</div>';
|
||||
html += buildPropsHtml(e.title);
|
||||
popup.innerHTML = html;
|
||||
popup.style.display = 'block';
|
||||
panelCurrentItem = e;
|
||||
panelCurrentType = 'edge';
|
||||
panelViewState = 'props';
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
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;
|
||||
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();
|
||||
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) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ---- expand / collapse ----
|
||||
var lastTapTime = 0, lastTapNode = null;
|
||||
var lastTapTime = 0, lastTapNode = null, singleTapTimer = null;
|
||||
var expandedBy = {}; // nodeId -> [childIds added by expanding that node]
|
||||
|
||||
// initial nodes are never collapsible
|
||||
@@ -346,19 +956,13 @@
|
||||
}
|
||||
|
||||
function expandNode(n) {
|
||||
var f = document.createElement('iframe');
|
||||
f.style.display = 'none';
|
||||
f.src = 'app://expand?nodeId=' + n.id;
|
||||
document.body.appendChild(f);
|
||||
setTimeout(function () { document.body.removeChild(f); }, 200);
|
||||
notifyApp('app://expand?nodeId=' + n.id);
|
||||
}
|
||||
|
||||
function collapseNode(n) {
|
||||
var childIds = expandedBy[n.id];
|
||||
if (!childIds) return;
|
||||
|
||||
// only remove children that aren't initial nodes and aren't
|
||||
// also owned by another still-expanded node
|
||||
var otherOwned = {};
|
||||
Object.keys(expandedBy).forEach(function (ownerId) {
|
||||
if (ownerId == n.id) return;
|
||||
@@ -370,7 +974,6 @@
|
||||
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) {
|
||||
if (expandedBy[rid]) {
|
||||
expandedBy[rid].forEach(function (cid) {
|
||||
@@ -380,19 +983,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
// remove edges connected to removed nodes
|
||||
edges = edges.filter(function (e) {
|
||||
return !toRemove[e.from] && !toRemove[e.to];
|
||||
});
|
||||
|
||||
// remove nodes
|
||||
nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; });
|
||||
Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; });
|
||||
|
||||
delete expandedBy[n.id];
|
||||
|
||||
// hide popup if the selected node was removed
|
||||
if (selected && toRemove[selected.id]) hidePopup();
|
||||
if (selected && toRemove[selected.id]) hidePanel();
|
||||
|
||||
recomputeDegrees();
|
||||
resumeLoop();
|
||||
@@ -407,7 +1007,7 @@
|
||||
|
||||
newNodes.forEach(function (d) {
|
||||
if (nodeById[d.id]) {
|
||||
addedIds.push(d.id); // track even existing ones as children
|
||||
addedIds.push(d.id);
|
||||
return;
|
||||
}
|
||||
var angle = Math.random() * 2 * Math.PI;
|
||||
@@ -429,13 +1029,22 @@
|
||||
});
|
||||
if (exists) 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;
|
||||
|
||||
recomputeDegrees();
|
||||
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) ----
|
||||
@@ -444,21 +1053,16 @@
|
||||
function notifyInteraction() {
|
||||
if (interacted) return;
|
||||
interacted = true;
|
||||
var f = document.createElement('iframe');
|
||||
f.style.display = 'none';
|
||||
f.src = 'app://interaction';
|
||||
document.body.appendChild(f);
|
||||
setTimeout(function () { document.body.removeChild(f); }, 200);
|
||||
notifyApp('app://interaction');
|
||||
}
|
||||
|
||||
function edgeAt(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;
|
||||
edges.forEach(function (e) {
|
||||
var a = nodeById[e.from], b = nodeById[e.to];
|
||||
if (!a || !b) return;
|
||||
// point-to-segment distance
|
||||
var dx = b.x - a.x, dy = b.y - a.y;
|
||||
var lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) return;
|
||||
@@ -487,6 +1091,7 @@
|
||||
|
||||
function handleStart(e) {
|
||||
if (e.touches) e.preventDefault();
|
||||
if (e.touches && e.touches.length >= 2) return; // pinch handled separately
|
||||
notifyInteraction();
|
||||
var p = coordFromEvent(e);
|
||||
ptrStartX = ptrEndX = p.x;
|
||||
@@ -524,23 +1129,30 @@
|
||||
if (!moved && dt < 300) {
|
||||
var hit = nodeAt(ptrStartX, ptrStartY);
|
||||
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) {
|
||||
lastTapNode = null; lastTapTime = 0;
|
||||
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
|
||||
toggleExpandCollapse(hit);
|
||||
} else if (hit) {
|
||||
lastTapNode = hit; lastTapTime = now;
|
||||
if (hit === selected) hidePopup();
|
||||
else showPopup(hit);
|
||||
// Delay panel open to distinguish from double-tap
|
||||
var tappedNode = hit;
|
||||
if (singleTapTimer) clearTimeout(singleTapTimer);
|
||||
singleTapTimer = setTimeout(function () {
|
||||
singleTapTimer = null;
|
||||
if (tappedNode === selected && panelOpen) hidePanel();
|
||||
else showNodePanel(tappedNode);
|
||||
}, 350);
|
||||
} else {
|
||||
lastTapNode = null; lastTapTime = 0;
|
||||
// no node hit — check if an edge was tapped
|
||||
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
|
||||
var hitEdge = edgeAt(ptrStartX, ptrStartY);
|
||||
if (hitEdge) {
|
||||
if (hitEdge === selectedEdge) hidePopup();
|
||||
else showEdgePopup(hitEdge);
|
||||
if (hitEdge === selectedEdge && panelOpen) hidePanel();
|
||||
else showEdgePanel(hitEdge);
|
||||
} else {
|
||||
hidePopup();
|
||||
if (panelOpen) hidePanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,10 +1178,6 @@
|
||||
canvas.addEventListener('mousemove', handleMove);
|
||||
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 ----
|
||||
canvas.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
@@ -577,7 +1185,6 @@
|
||||
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10);
|
||||
// zoom toward mouse position
|
||||
panX = mx - (mx - panX) * (newZoom / zoom);
|
||||
panY = my - (my - panY) * (newZoom / zoom);
|
||||
zoom = newZoom;
|
||||
@@ -638,14 +1245,12 @@
|
||||
|
||||
function init() {
|
||||
resize();
|
||||
// WebView may not have layout yet — retry until we get real dimensions
|
||||
if ((W <= 1 || H <= 1) && initRetries < 20) {
|
||||
initRetries++;
|
||||
setTimeout(init, 50);
|
||||
return;
|
||||
}
|
||||
placeInitial();
|
||||
// center the camera on the graph with the pre-computed zoom
|
||||
panX = W / 2 - (W / 2) * zoom;
|
||||
panY = H / 2 - (H / 2) * zoom;
|
||||
loop();
|
||||
@@ -656,7 +1261,6 @@
|
||||
draw();
|
||||
});
|
||||
|
||||
// Use rAF to ensure the browser has laid out the canvas before we read its size
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(init);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<UseMaui>true</UseMaui>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>
|
||||
<!-- XC0023: elements inside DataTemplates that bind to both the item model and the parent
|
||||
page ViewModel via Source={x:Reference} cannot share a single compiled DataType.
|
||||
x:DataType="{x:Null}" on those elements is intentional; suppress the hint. -->
|
||||
<NoWarn>$(NoWarn);XC0023</NoWarn>
|
||||
<!-- CS0618: ViewCell is deprecated but converting ListView to CollectionView is a larger
|
||||
refactor. The MAUI XAML source generator emits this on the generated partial class
|
||||
for ConnectionCell/LicenseCell which we cannot annotate directly. -->
|
||||
<NoWarn>$(NoWarn);CS0618</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
@@ -15,6 +24,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.20" />
|
||||
<PackageReference Include="Neo4jClient" Version="4.1.18" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
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()],
|
||||
});
|
||||
4375
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": "^7.0.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"]
|
||||
}
|
||||