Implemented with Antigravity.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SNote.Models;
|
||||
|
||||
namespace SNote.Data;
|
||||
|
||||
public class ClientDbContext : DbContext
|
||||
{
|
||||
public static string ActiveDbName { get; set; } = "snote_client.db";
|
||||
public static string? ActiveDbPassword { get; set; } = null;
|
||||
|
||||
public DbSet<Node> Nodes => Set<Node>();
|
||||
public DbSet<LocalConfig> Configs => Set<LocalConfig>();
|
||||
public DbSet<UserDirectoryEntry> UserDirectory => Set<UserDirectoryEntry>();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
if (!optionsBuilder.IsConfigured)
|
||||
{
|
||||
var folder = Environment.SpecialFolder.LocalApplicationData;
|
||||
var path = Environment.GetFolderPath(folder);
|
||||
var dbPath = Path.Combine(path, "SNote");
|
||||
|
||||
if (!Directory.Exists(dbPath))
|
||||
{
|
||||
Directory.CreateDirectory(dbPath);
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(dbPath, ActiveDbName);
|
||||
|
||||
if (string.IsNullOrEmpty(ActiveDbPassword))
|
||||
{
|
||||
optionsBuilder.UseSqlite($"Data Source={fullPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
optionsBuilder.UseSqlite($"Data Source={fullPath};Password={ActiveDbPassword}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class LocalConfig
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
public string AuthToken { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string MasterKey { get; set; } = string.Empty; // decrypted master key (k_master)
|
||||
public bool IsOnlineMode { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SNote.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
[Key]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public string EncryptedKey { get; set; } = string.Empty; // User's master key (k_master) encrypted by password
|
||||
}
|
||||
|
||||
public class Node
|
||||
{
|
||||
[Key]
|
||||
public string Uuid { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "Note"; // "Folder" or "Note"
|
||||
public string Content { get; set; } = string.Empty; // Encrypted text/JSON or plaintext (if everyone has View)
|
||||
public string OwnerUsername { get; set; } = string.Empty;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
}
|
||||
|
||||
public class NodeKey
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
public string NodeUuid { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string EncryptedNodeKey { get; set; } = string.Empty; // k_0 encrypted by User's master key (k_master)
|
||||
}
|
||||
|
||||
public class PendingShare
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
public string NodeUuid { get; set; } = string.Empty;
|
||||
public string RecipientUsername { get; set; } = string.Empty;
|
||||
public string EncryptedNodeKeyWithK1 { get; set; } = string.Empty; // k_0 encrypted by passkey k_1
|
||||
}
|
||||
|
||||
public class AclEntry
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
public string NodeUuid { get; set; } = string.Empty;
|
||||
public string UserOrRole { get; set; } = string.Empty; // username or "everyone"
|
||||
public bool CanView { get; set; } = false;
|
||||
public bool CanEdit { get; set; } = false;
|
||||
public bool CanDelete { get; set; } = false;
|
||||
public bool CanRename { get; set; } = false;
|
||||
}
|
||||
|
||||
public class PeerServer
|
||||
{
|
||||
[Key]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
public bool IsDestination { get; set; } = false;
|
||||
}
|
||||
|
||||
public class UserDirectoryEntry
|
||||
{
|
||||
[Key]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string UserGuid { get; set; } = string.Empty;
|
||||
public string RemoteServerUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
@@ -19,5 +19,7 @@
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SNote.Services;
|
||||
|
||||
public class CryptoService
|
||||
{
|
||||
private const int Iterations = 10000;
|
||||
private const int KeySize = 32; // 256 bit
|
||||
|
||||
// Derives an AES-256 key from a password and a salt (derived from username)
|
||||
public string DeriveKeyFromPassword(string password, string username)
|
||||
{
|
||||
var salt = Encoding.UTF8.GetBytes(username.ToLower() + "-snote-salt-system");
|
||||
using var algorithm = new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256);
|
||||
return Convert.ToBase64String(algorithm.GetBytes(KeySize));
|
||||
}
|
||||
|
||||
// Generates a new random cryptographically secure master key (Base64 string)
|
||||
public string GenerateMasterKey()
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.GenerateKey();
|
||||
return Convert.ToBase64String(aes.Key);
|
||||
}
|
||||
|
||||
// Encrypts plaintext using AES-256 with a derived key
|
||||
public string Encrypt(string plaintext, string base64Key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintext)) return plaintext;
|
||||
|
||||
try
|
||||
{
|
||||
var keyBytes = Convert.FromBase64String(base64Key);
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = keyBytes;
|
||||
aes.GenerateIV();
|
||||
var iv = aes.IV;
|
||||
|
||||
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
// Prepend IV
|
||||
ms.Write(iv, 0, iv.Length);
|
||||
|
||||
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
|
||||
using (var sw = new StreamWriter(cs, Encoding.UTF8))
|
||||
{
|
||||
sw.Write(plaintext);
|
||||
}
|
||||
|
||||
return Convert.ToBase64String(ms.ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Client encryption error: {ex.Message}");
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypts ciphertext (prepended with IV) using a derived key
|
||||
public string Decrypt(string ciphertext, string base64Key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ciphertext)) return ciphertext;
|
||||
|
||||
try
|
||||
{
|
||||
var fullCipher = Convert.FromBase64String(ciphertext);
|
||||
var keyBytes = Convert.FromBase64String(base64Key);
|
||||
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = keyBytes;
|
||||
|
||||
var iv = new byte[16];
|
||||
var cipherTextBytes = new byte[fullCipher.Length - 16];
|
||||
|
||||
Buffer.BlockCopy(fullCipher, 0, iv, 0, 16);
|
||||
Buffer.BlockCopy(fullCipher, 16, cipherTextBytes, 0, cipherTextBytes.Length);
|
||||
|
||||
aes.IV = iv;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
|
||||
using var ms = new MemoryStream(cipherTextBytes);
|
||||
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
|
||||
using var sr = new StreamReader(cs, Encoding.UTF8);
|
||||
|
||||
return sr.ReadToEnd();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Client decryption error: {ex.Message}");
|
||||
return ciphertext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SNote.Data;
|
||||
using SNote.Models;
|
||||
|
||||
namespace SNote.Services;
|
||||
|
||||
public class SyncService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly CryptoService _cryptoService;
|
||||
|
||||
public SyncService(CryptoService cryptoService)
|
||||
{
|
||||
_httpClient = new HttpClient();
|
||||
_cryptoService = cryptoService;
|
||||
}
|
||||
|
||||
// Register a new account on the server
|
||||
public async Task<(bool success, string error)> RegisterAsync(string username, string password, string serverUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanUrl = serverUrl.TrimEnd('/') + "/api/auth/register";
|
||||
|
||||
// Generate a random master key for the user (k_master)
|
||||
var masterKey = _cryptoService.GenerateMasterKey();
|
||||
|
||||
// Derive key from password to encrypt the master key
|
||||
var derivedKey = _cryptoService.DeriveKeyFromPassword(password, username);
|
||||
|
||||
// Encrypt the master key using the derived key
|
||||
var encryptedKey = _cryptoService.Encrypt(masterKey, derivedKey);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
username,
|
||||
password, // For server verification / hashing
|
||||
encryptedKey
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync(cleanUrl, content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
var errorMsg = await response.Content.ReadAsStringAsync();
|
||||
return (false, string.IsNullOrEmpty(errorMsg) ? response.StatusCode.ToString() : errorMsg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Login and retrieve the encrypted master key
|
||||
public async Task<(bool success, string error, LocalConfig? config)> LoginAsync(string username, string password, string serverUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanUrl = serverUrl.TrimEnd('/') + "/api/auth/login";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
username,
|
||||
password
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync(cleanUrl, content);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return (false, "Invalid username or password.", null);
|
||||
}
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(responseJson);
|
||||
var root = doc.RootElement;
|
||||
var token = root.GetProperty("token").GetString() ?? string.Empty;
|
||||
var encryptedKey = root.GetProperty("encryptedKey").GetString() ?? string.Empty;
|
||||
|
||||
// Decrypt the master key locally using the derived key
|
||||
var derivedKey = _cryptoService.DeriveKeyFromPassword(password, username);
|
||||
var masterKey = _cryptoService.Decrypt(encryptedKey, derivedKey);
|
||||
|
||||
var config = new LocalConfig
|
||||
{
|
||||
Username = username,
|
||||
ServerUrl = serverUrl,
|
||||
AuthToken = token,
|
||||
MasterKey = masterKey,
|
||||
IsOnlineMode = true
|
||||
};
|
||||
|
||||
return (true, string.Empty, config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept a cryptographic node share using Guid + k1
|
||||
public async Task<(bool success, string error)> AcceptShareAsync(string guid, string k1, LocalConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes/accept-share";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
nodeUuid = guid,
|
||||
passkeyK1 = k1
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, cleanUrl)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
|
||||
request.Headers.Add("X-Encryption-Key", config.MasterKey);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (false, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Share a node with another user
|
||||
public async Task<(bool success, string error, string guid)> ShareNodeAsync(string nodeUuid, string recipient, string k1, LocalConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes/share";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
nodeUuid,
|
||||
recipientUsername = recipient,
|
||||
passkeyK1 = k1
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, cleanUrl)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
|
||||
request.Headers.Add("X-Encryption-Key", config.MasterKey);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var respContent = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(respContent);
|
||||
var guidStr = doc.RootElement.GetProperty("guid").GetString() ?? nodeUuid;
|
||||
return (true, string.Empty, guidStr);
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (false, error, nodeUuid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message, nodeUuid);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local offline SQLite database with server
|
||||
public async Task<(bool success, string error)> SyncNodesAsync(LocalConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = new ClientDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// 1. Fetch remote nodes list
|
||||
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, cleanUrl);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
|
||||
request.Headers.Add("X-Encryption-Key", config.MasterKey);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return (false, $"Server returned error: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
var remoteNodes = JsonSerializer.Deserialize<List<NodeDto>>(responseJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (remoteNodes == null) return (false, "Failed to deserialize remote nodes.");
|
||||
|
||||
// 2. Fetch local nodes list
|
||||
var localNodes = await db.Nodes.ToListAsync();
|
||||
|
||||
// 3. Process differences
|
||||
foreach (var remote in remoteNodes)
|
||||
{
|
||||
var local = localNodes.FirstOrDefault(l => l.Uuid == remote.Uuid);
|
||||
|
||||
if (local == null)
|
||||
{
|
||||
// Add remote node locally
|
||||
db.Nodes.Add(new Node
|
||||
{
|
||||
Uuid = remote.Uuid,
|
||||
Name = remote.Name,
|
||||
Type = remote.Type,
|
||||
Content = remote.Content,
|
||||
OwnerUsername = remote.OwnerUsername,
|
||||
UpdatedAt = remote.UpdatedAt,
|
||||
IsDeleted = false
|
||||
});
|
||||
}
|
||||
else if (local.UpdatedAt < remote.UpdatedAt)
|
||||
{
|
||||
// Update local node with newer remote content
|
||||
local.Name = remote.Name;
|
||||
local.Type = remote.Type;
|
||||
local.Content = remote.Content;
|
||||
local.OwnerUsername = remote.OwnerUsername;
|
||||
local.UpdatedAt = remote.UpdatedAt;
|
||||
local.IsDeleted = false; // Sync undeletes if remote is active
|
||||
}
|
||||
else if (local.UpdatedAt > remote.UpdatedAt)
|
||||
{
|
||||
// Local is newer: push update to server
|
||||
await PushNodeToServerAsync(local, config);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are local nodes that the server doesn't have
|
||||
var remoteUuids = remoteNodes.Select(r => r.Uuid).ToHashSet();
|
||||
foreach (var local in localNodes)
|
||||
{
|
||||
if (!remoteUuids.Contains(local.Uuid) && !local.IsDeleted)
|
||||
{
|
||||
// Node exists only locally, upload it to the server
|
||||
await UploadNewNodeToServerAsync(local, config);
|
||||
}
|
||||
else if (local.IsDeleted && remoteUuids.Contains(local.Uuid))
|
||||
{
|
||||
// Soft deleted locally, notify server
|
||||
await DeleteNodeOnServerAsync(local.Uuid, config);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return (true, string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushNodeToServerAsync(Node localNode, LocalConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanUrl = config.ServerUrl.TrimEnd('/') + $"/api/nodes/{localNode.Uuid}";
|
||||
var payload = new
|
||||
{
|
||||
name = localNode.Name,
|
||||
content = localNode.Content
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Put, cleanUrl)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
|
||||
request.Headers.Add("X-Encryption-Key", config.MasterKey);
|
||||
|
||||
await _httpClient.SendAsync(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error pushing node {localNode.Uuid} to server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadNewNodeToServerAsync(Node localNode, LocalConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes";
|
||||
var payload = new
|
||||
{
|
||||
uuid = localNode.Uuid,
|
||||
name = localNode.Name,
|
||||
type = localNode.Type,
|
||||
content = localNode.Content
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, cleanUrl)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
|
||||
request.Headers.Add("X-Encryption-Key", config.MasterKey);
|
||||
|
||||
await _httpClient.SendAsync(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error uploading node {localNode.Uuid} to server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteNodeOnServerAsync(string uuid, LocalConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanUrl = config.ServerUrl.TrimEnd('/') + $"/api/nodes/{uuid}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Delete, cleanUrl);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
|
||||
|
||||
await _httpClient.SendAsync(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error deleting node {uuid} on server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record SyncNodeState(string Uuid, DateTime UpdatedAt, bool IsDeleted);
|
||||
public record SyncExchangeRequest(List<SyncNodeState> Items);
|
||||
public record SyncPushItem(Node Node, List<AclEntry> AclEntries, List<NodeKey> NodeKeys);
|
||||
public record SyncExchangeResponse(List<SyncPushItem> ToPush, List<string> ToRequest);
|
||||
public record NodeDto(
|
||||
string Uuid,
|
||||
string Name,
|
||||
string Type,
|
||||
string Content,
|
||||
string OwnerUsername,
|
||||
DateTime UpdatedAt,
|
||||
bool IsOwner,
|
||||
bool CanView,
|
||||
bool CanEdit,
|
||||
bool CanDelete,
|
||||
bool CanRename,
|
||||
bool IsPublic
|
||||
);
|
||||
@@ -1,9 +1,875 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SNote.Data;
|
||||
using SNote.Models;
|
||||
using SNote.Services;
|
||||
|
||||
namespace SNote.ViewModels;
|
||||
|
||||
public class NotebookItem
|
||||
{
|
||||
public Node Node { get; set; }
|
||||
public string Uuid => Node.Uuid;
|
||||
public string Name => Node.Name;
|
||||
public string Type => Node.Type;
|
||||
public bool IsFolder => Type == "Folder";
|
||||
public bool IsNote => Type == "Note";
|
||||
public ObservableCollection<NotebookItem> Children { get; } = new();
|
||||
|
||||
public NotebookItem(Node node)
|
||||
{
|
||||
Node = node;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string _greeting = "Welcome to Avalonia!";
|
||||
private readonly CryptoService _cryptoService;
|
||||
private readonly SyncService _syncService;
|
||||
private LocalConfig? _config;
|
||||
|
||||
// View layout responsive state
|
||||
[ObservableProperty] private bool _isOnline = false;
|
||||
[ObservableProperty] private bool _isLoggedIn = false;
|
||||
[ObservableProperty] private string _username = "Offline User";
|
||||
[ObservableProperty] private string _serverUrl = "http://localhost:5000";
|
||||
[ObservableProperty] private string _loginPassword = "";
|
||||
[ObservableProperty] private string _syncStatus = "Offline Mode";
|
||||
|
||||
// Narrow vs Full mode states
|
||||
[ObservableProperty] private bool _isNarrowMode = false;
|
||||
[ObservableProperty] private bool _isSidebarVisible = true;
|
||||
[ObservableProperty] private bool _isContentVisible = false;
|
||||
|
||||
// Editor / Selection state
|
||||
[ObservableProperty] private Node? _selectedNode;
|
||||
[ObservableProperty] private string _noteName = "";
|
||||
[ObservableProperty] private string _noteContent = "";
|
||||
|
||||
// Dialog state controllers (shown via VM binding or simple toggles)
|
||||
[ObservableProperty] private bool _isLinkDialogVisible = false;
|
||||
[ObservableProperty] private bool _isShareDialogVisible = false;
|
||||
[ObservableProperty] private bool _isAcceptDialogVisible = false;
|
||||
[ObservableProperty] private bool _isCreateFolderDialogVisible = false;
|
||||
[ObservableProperty] private string _newFolderName = "";
|
||||
|
||||
// New properties for Rename Dialog and Note Migration Dialog
|
||||
[ObservableProperty] private bool _isRenameDialogVisible = false;
|
||||
[ObservableProperty] private string _renameTargetName = "";
|
||||
[ObservableProperty] private bool _isMigrateDialogVisible = false;
|
||||
[ObservableProperty] private string? _selectedLocalAccount;
|
||||
|
||||
// Share results info
|
||||
[ObservableProperty] private string _shareGuid = "";
|
||||
[ObservableProperty] private string _sharePasskey = "";
|
||||
|
||||
// Accept Share entry
|
||||
[ObservableProperty] private string _acceptGuid = "";
|
||||
[ObservableProperty] private string _acceptPasskey = "";
|
||||
|
||||
// Tree selection
|
||||
[ObservableProperty] private NotebookItem? _selectedNotebookItem;
|
||||
|
||||
partial void OnSelectedNotebookItemChanged(NotebookItem? value)
|
||||
{
|
||||
if (value != null)
|
||||
{
|
||||
SelectNode(value.Node);
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<NotebookItem> NotebookTree { get; } = new();
|
||||
public ObservableCollection<Node> CurrentNotesList { get; } = new();
|
||||
public ObservableCollection<Node> AllFoldersList { get; } = new(); // Used in Link to... dialog
|
||||
public ObservableCollection<string> LocalAccounts { get; } = new();
|
||||
|
||||
partial void OnSelectedLocalAccountChanged(string? value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
Username = value;
|
||||
}
|
||||
}
|
||||
|
||||
public void ScanLocalAccounts()
|
||||
{
|
||||
LocalAccounts.Clear();
|
||||
try
|
||||
{
|
||||
var folder = Environment.SpecialFolder.LocalApplicationData;
|
||||
var path = Environment.GetFolderPath(folder);
|
||||
var dbPath = Path.Combine(path, "SNote");
|
||||
if (Directory.Exists(dbPath))
|
||||
{
|
||||
using var globalDb = new ClientDbContext();
|
||||
globalDb.Database.EnsureCreated();
|
||||
|
||||
var entries = globalDb.UserDirectory.ToList();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var file = Path.Combine(dbPath, $"snote_{entry.UserGuid}.db");
|
||||
if (File.Exists(file) && !LocalAccounts.Contains(entry.Username))
|
||||
{
|
||||
LocalAccounts.Add(entry.Username);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error scanning local accounts: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
_cryptoService = new CryptoService();
|
||||
_syncService = new SyncService(_cryptoService);
|
||||
|
||||
// Bootstrap local SQLite DB
|
||||
using var db = new ClientDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// Load configuration if exists
|
||||
_config = db.Configs.OrderByDescending(c => c.Id).FirstOrDefault();
|
||||
if (_config != null)
|
||||
{
|
||||
Username = _config.Username;
|
||||
ServerUrl = _config.ServerUrl;
|
||||
IsOnline = _config.IsOnlineMode;
|
||||
}
|
||||
|
||||
RefreshNotebookTree();
|
||||
ScanLocalAccounts();
|
||||
}
|
||||
|
||||
// Handles window resizing and adaptive column toggles
|
||||
public void UpdateWidthMode(double width)
|
||||
{
|
||||
IsNarrowMode = width < 700;
|
||||
if (IsNarrowMode)
|
||||
{
|
||||
// In narrow mode, if editing, hide sidebar. Otherwise, show sidebar.
|
||||
IsSidebarVisible = SelectedNode == null;
|
||||
IsContentVisible = SelectedNode != null;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsSidebarVisible = true;
|
||||
IsContentVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void GoBack()
|
||||
{
|
||||
SelectedNode = null;
|
||||
if (IsNarrowMode)
|
||||
{
|
||||
IsSidebarVisible = true;
|
||||
IsContentVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize local database with some default folders if empty
|
||||
public void RefreshNotebookTree()
|
||||
{
|
||||
NotebookTree.Clear();
|
||||
CurrentNotesList.Clear();
|
||||
AllFoldersList.Clear();
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
var allNodes = db.Nodes.ToList();
|
||||
|
||||
var activeNodes = allNodes.Where(n => !n.IsDeleted).ToList();
|
||||
|
||||
// Populate folders for dialog select
|
||||
var folders = activeNodes.Where(n => n.Type == "Folder").ToList();
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
AllFoldersList.Add(folder);
|
||||
}
|
||||
|
||||
// Map Node Uuid -> NotebookItem wrapper
|
||||
var itemMap = activeNodes.ToDictionary(n => n.Uuid, n => new NotebookItem(n));
|
||||
|
||||
var childUuids = new System.Collections.Generic.HashSet<string>();
|
||||
|
||||
// Build tree links
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
if (itemMap.TryGetValue(folder.Uuid, out var folderItem))
|
||||
{
|
||||
try
|
||||
{
|
||||
var childrenList = JsonSerializer.Deserialize<List<string>>(folder.Content) ?? new();
|
||||
foreach (var childUuid in childrenList)
|
||||
{
|
||||
if (itemMap.TryGetValue(childUuid, out var childItem))
|
||||
{
|
||||
folderItem.Children.Add(childItem);
|
||||
childUuids.Add(childUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Roots are all items that are not children of any folder
|
||||
var roots = itemMap.Values.Where(item => !childUuids.Contains(item.Uuid)).ToList();
|
||||
foreach (var root in roots)
|
||||
{
|
||||
NotebookTree.Add(root);
|
||||
}
|
||||
|
||||
// If a node was selected, refresh it
|
||||
if (SelectedNode != null)
|
||||
{
|
||||
var refreshed = allNodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid && !n.IsDeleted);
|
||||
if (refreshed != null)
|
||||
{
|
||||
SelectNode(refreshed);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedNode = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void SelectNode(Node node)
|
||||
{
|
||||
SelectedNode = node;
|
||||
NoteName = node.Name;
|
||||
|
||||
if (node.Type == "Folder")
|
||||
{
|
||||
CurrentNotesList.Clear();
|
||||
using var db = new ClientDbContext();
|
||||
|
||||
// Fetch children
|
||||
try
|
||||
{
|
||||
var childrenUuids = JsonSerializer.Deserialize<List<string>>(node.Content) ?? new();
|
||||
var children = db.Nodes.Where(n => childrenUuids.Contains(n.Uuid) && !n.IsDeleted).ToList();
|
||||
foreach (var child in children)
|
||||
{
|
||||
CurrentNotesList.Add(child);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Note node: bind content
|
||||
NoteContent = node.Content;
|
||||
}
|
||||
|
||||
if (IsNarrowMode)
|
||||
{
|
||||
IsSidebarVisible = false;
|
||||
IsContentVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenCreateFolderDialog()
|
||||
{
|
||||
NewFolderName = "New Folder";
|
||||
IsCreateFolderDialogVisible = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ConfirmCreateFolder()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewFolderName)) return;
|
||||
CreateFolder(NewFolderName);
|
||||
IsCreateFolderDialogVisible = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateFolder(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) name = "New Folder";
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
string uuid;
|
||||
do
|
||||
{
|
||||
uuid = Guid.NewGuid().ToString();
|
||||
} while (db.Nodes.Any(n => n.Uuid == uuid));
|
||||
|
||||
var folder = new Node
|
||||
{
|
||||
Uuid = uuid,
|
||||
Name = name,
|
||||
Type = "Folder",
|
||||
Content = "[]",
|
||||
OwnerUsername = Username,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Nodes.Add(folder);
|
||||
|
||||
// If we have an active folder selected, link this new folder inside it
|
||||
if (SelectedNode != null && SelectedNode.Type == "Folder")
|
||||
{
|
||||
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
|
||||
if (parent != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
|
||||
childList.Add(folder.Uuid);
|
||||
parent.Content = JsonSerializer.Serialize(childList);
|
||||
parent.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
db.SaveChanges();
|
||||
RefreshNotebookTree();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateNote(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) name = "New Note";
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
string uuid;
|
||||
do
|
||||
{
|
||||
uuid = Guid.NewGuid().ToString();
|
||||
} while (db.Nodes.Any(n => n.Uuid == uuid));
|
||||
|
||||
var note = new Node
|
||||
{
|
||||
Uuid = uuid,
|
||||
Name = name,
|
||||
Type = "Note",
|
||||
Content = "Write something here...",
|
||||
OwnerUsername = Username,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Nodes.Add(note);
|
||||
|
||||
// Must link inside active folder
|
||||
if (SelectedNode != null && SelectedNode.Type == "Folder")
|
||||
{
|
||||
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
|
||||
if (parent != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
|
||||
childList.Add(note.Uuid);
|
||||
parent.Content = JsonSerializer.Serialize(childList);
|
||||
parent.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
db.SaveChanges();
|
||||
RefreshNotebookTree();
|
||||
SelectNode(note);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveActiveNote()
|
||||
{
|
||||
if (SelectedNode == null || SelectedNode.Type == "Folder") return;
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
var note = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
|
||||
if (note != null)
|
||||
{
|
||||
note.Name = NoteName;
|
||||
note.Content = NoteContent;
|
||||
note.UpdatedAt = DateTime.UtcNow;
|
||||
db.SaveChanges();
|
||||
|
||||
SyncStatus = "Saved locally.";
|
||||
RefreshNotebookTree();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RenameNode(string newName)
|
||||
{
|
||||
if (SelectedNode == null || string.IsNullOrWhiteSpace(newName)) return;
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
|
||||
if (node != null)
|
||||
{
|
||||
node.Name = newName;
|
||||
node.UpdatedAt = DateTime.UtcNow;
|
||||
db.SaveChanges();
|
||||
RefreshNotebookTree();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteNode()
|
||||
{
|
||||
if (SelectedNode == null) return;
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
|
||||
if (node != null)
|
||||
{
|
||||
node.IsDeleted = true;
|
||||
node.UpdatedAt = DateTime.UtcNow;
|
||||
db.SaveChanges();
|
||||
SelectedNode = null;
|
||||
RefreshNotebookTree();
|
||||
GoBack();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenLinkDialog()
|
||||
{
|
||||
if (SelectedNode == null) return;
|
||||
IsLinkDialogVisible = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseLinkDialog()
|
||||
{
|
||||
IsLinkDialogVisible = false;
|
||||
}
|
||||
|
||||
// Symbolic linking of folders/notes
|
||||
[RelayCommand]
|
||||
public void LinkNodeToFolder(Node targetFolder)
|
||||
{
|
||||
if (SelectedNode == null || targetFolder == null || targetFolder.Uuid == SelectedNode.Uuid) return;
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == targetFolder.Uuid);
|
||||
if (parent != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
|
||||
if (!childList.Contains(SelectedNode.Uuid))
|
||||
{
|
||||
childList.Add(SelectedNode.Uuid);
|
||||
parent.Content = JsonSerializer.Serialize(childList);
|
||||
parent.UpdatedAt = DateTime.UtcNow;
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
IsLinkDialogVisible = false;
|
||||
RefreshNotebookTree();
|
||||
SyncStatus = $"Linked successfully to {targetFolder.Name}.";
|
||||
}
|
||||
|
||||
// Start sharing flow: prompts the dialog
|
||||
[RelayCommand]
|
||||
private void OpenShareDialog()
|
||||
{
|
||||
if (SelectedNode == null) return;
|
||||
ShareGuid = SelectedNode.Uuid;
|
||||
SharePasskey = "share-" + new Random().Next(1000, 9999);
|
||||
IsShareDialogVisible = true;
|
||||
}
|
||||
|
||||
// Initiate sharing on the server
|
||||
[RelayCommand]
|
||||
private async Task ConfirmShareNode(string recipient)
|
||||
{
|
||||
if (SelectedNode == null || _config == null || string.IsNullOrEmpty(recipient)) return;
|
||||
|
||||
SyncStatus = "Sharing with server...";
|
||||
var (success, error, guid) = await _syncService.ShareNodeAsync(SelectedNode.Uuid, recipient, SharePasskey, _config);
|
||||
|
||||
if (success)
|
||||
{
|
||||
SyncStatus = "Node shared! Share GUID and passkey shown.";
|
||||
ShareGuid = guid; // Display GUID for user copy
|
||||
}
|
||||
else
|
||||
{
|
||||
SyncStatus = $"Share failed: {error}";
|
||||
IsShareDialogVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenAcceptDialog()
|
||||
{
|
||||
IsAcceptDialogVisible = true;
|
||||
}
|
||||
|
||||
// Accept share flow
|
||||
[RelayCommand]
|
||||
private async Task ConfirmAcceptShare()
|
||||
{
|
||||
if (_config == null || string.IsNullOrWhiteSpace(AcceptGuid) || string.IsNullOrWhiteSpace(AcceptPasskey)) return;
|
||||
|
||||
SyncStatus = "Accepting share from server...";
|
||||
var (success, error) = await _syncService.AcceptShareAsync(AcceptGuid, AcceptPasskey, _config);
|
||||
if (success)
|
||||
{
|
||||
SyncStatus = "Share accepted. Synchronizing to download...";
|
||||
|
||||
// Sync now to pull it
|
||||
await TriggerSync();
|
||||
|
||||
IsAcceptDialogVisible = false;
|
||||
AcceptGuid = "";
|
||||
AcceptPasskey = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
SyncStatus = $"Failed to accept share: {error}";
|
||||
}
|
||||
}
|
||||
|
||||
// Rename Dialog Commands
|
||||
[RelayCommand]
|
||||
private void OpenRenameDialog(NotebookItem item)
|
||||
{
|
||||
if (item == null) return;
|
||||
SelectedNotebookItem = item;
|
||||
RenameTargetName = item.Node.Name;
|
||||
IsRenameDialogVisible = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ConfirmRename()
|
||||
{
|
||||
if (SelectedNode == null || string.IsNullOrWhiteSpace(RenameTargetName))
|
||||
{
|
||||
IsRenameDialogVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new ClientDbContext();
|
||||
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
|
||||
if (node != null)
|
||||
{
|
||||
node.Name = RenameTargetName;
|
||||
node.UpdatedAt = DateTime.UtcNow;
|
||||
db.SaveChanges();
|
||||
RefreshNotebookTree();
|
||||
}
|
||||
IsRenameDialogVisible = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelRename()
|
||||
{
|
||||
IsRenameDialogVisible = false;
|
||||
}
|
||||
|
||||
// Migration Commands
|
||||
[RelayCommand]
|
||||
private async Task ConfirmMigration()
|
||||
{
|
||||
IsMigrateDialogVisible = false;
|
||||
SyncStatus = "Migrating offline notes to encrypted database...";
|
||||
|
||||
try
|
||||
{
|
||||
var userGuid = "";
|
||||
using (var globalDb = new ClientDbContext())
|
||||
{
|
||||
ClientDbContext.ActiveDbName = "snote_client.db";
|
||||
ClientDbContext.ActiveDbPassword = null;
|
||||
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
|
||||
if (entry != null)
|
||||
{
|
||||
userGuid = entry.UserGuid;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userGuid))
|
||||
{
|
||||
SyncStatus = "Migration failed: User directory mapping not found.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all nodes from the local offline database
|
||||
List<Node> localNodes;
|
||||
using (var globalDb = new ClientDbContext())
|
||||
{
|
||||
ClientDbContext.ActiveDbName = "snote_client.db";
|
||||
ClientDbContext.ActiveDbPassword = null;
|
||||
localNodes = globalDb.Nodes.Where(n => !n.IsDeleted).ToList();
|
||||
}
|
||||
|
||||
// Switch back to the secure DB
|
||||
var encryptionKey = _cryptoService.DeriveKeyFromPassword(LoginPassword, Username);
|
||||
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
|
||||
ClientDbContext.ActiveDbPassword = encryptionKey;
|
||||
|
||||
using (var userDb = new ClientDbContext())
|
||||
{
|
||||
userDb.Database.EnsureCreated();
|
||||
|
||||
var userMasterKey = _config?.MasterKey;
|
||||
if (string.IsNullOrEmpty(userMasterKey))
|
||||
{
|
||||
SyncStatus = "Migration failed: Master key not loaded.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var node in localNodes)
|
||||
{
|
||||
// Encrypt node content with a newly generated node-specific key k_0
|
||||
using var aes = System.Security.Cryptography.Aes.Create();
|
||||
aes.GenerateKey();
|
||||
var k0 = Convert.ToBase64String(aes.Key);
|
||||
|
||||
var secureNode = new Node
|
||||
{
|
||||
Uuid = node.Uuid,
|
||||
Name = node.Name,
|
||||
Type = node.Type,
|
||||
OwnerUsername = Username,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
IsDeleted = false
|
||||
};
|
||||
|
||||
// Encrypt node content using raw key k0
|
||||
secureNode.Content = _cryptoService.Encrypt(node.Content, k0);
|
||||
|
||||
// Create the encrypted node key mapping k0 encrypted by k_master
|
||||
var secureKey = new NodeKey
|
||||
{
|
||||
NodeUuid = node.Uuid,
|
||||
Username = Username,
|
||||
EncryptedNodeKey = _cryptoService.Encrypt(k0, userMasterKey)
|
||||
};
|
||||
|
||||
// Add permission
|
||||
var acl = new AclEntry
|
||||
{
|
||||
NodeUuid = node.Uuid,
|
||||
UserOrRole = Username,
|
||||
CanView = true,
|
||||
CanEdit = true,
|
||||
CanDelete = true,
|
||||
CanRename = true
|
||||
};
|
||||
|
||||
userDb.Nodes.Add(secureNode);
|
||||
userDb.Database.ExecuteSqlRaw(
|
||||
"INSERT OR IGNORE INTO NodeKeys (NodeUuid, Username, EncryptedNodeKey) VALUES ({0}, {1}, {2})",
|
||||
secureKey.NodeUuid, secureKey.Username, secureKey.EncryptedNodeKey);
|
||||
}
|
||||
|
||||
await userDb.SaveChangesAsync();
|
||||
}
|
||||
|
||||
SyncStatus = "Local notes migrated successfully! Triggering sync upload...";
|
||||
await TriggerSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SyncStatus = $"Migration error: {ex.Message}";
|
||||
Console.WriteLine($"Migration error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CancelMigration()
|
||||
{
|
||||
IsMigrateDialogVisible = false;
|
||||
SyncStatus = "Migration skipped. Syncing empty account...";
|
||||
await TriggerSync();
|
||||
}
|
||||
|
||||
// Account registration
|
||||
[RelayCommand]
|
||||
private async Task TriggerRegister(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(password)) return;
|
||||
|
||||
SyncStatus = "Registering on server...";
|
||||
var (success, error) = await _syncService.RegisterAsync(Username, password, ServerUrl);
|
||||
if (success)
|
||||
{
|
||||
SyncStatus = "Registered! You can now log in.";
|
||||
|
||||
// Also pre-create their UserDirectoryEntry so they have a GUID pre-generated!
|
||||
using (var globalDb = new ClientDbContext())
|
||||
{
|
||||
globalDb.Database.EnsureCreated();
|
||||
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
|
||||
if (entry == null)
|
||||
{
|
||||
var userGuid = Guid.NewGuid().ToString();
|
||||
entry = new UserDirectoryEntry
|
||||
{
|
||||
Username = Username,
|
||||
UserGuid = userGuid,
|
||||
RemoteServerUrl = ServerUrl
|
||||
};
|
||||
globalDb.UserDirectory.Add(entry);
|
||||
globalDb.SaveChanges();
|
||||
}
|
||||
}
|
||||
ScanLocalAccounts();
|
||||
}
|
||||
else
|
||||
{
|
||||
SyncStatus = $"Registration failed: {error}";
|
||||
}
|
||||
}
|
||||
|
||||
// Login and online activation
|
||||
[RelayCommand]
|
||||
private async Task TriggerLogin()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(LoginPassword)) return;
|
||||
|
||||
SyncStatus = "Logging in to server...";
|
||||
var (success, error, config) = await _syncService.LoginAsync(Username, LoginPassword, ServerUrl);
|
||||
if (success && config != null)
|
||||
{
|
||||
_config = config;
|
||||
|
||||
// Switch database to the secure account database!
|
||||
string userGuid;
|
||||
using (var globalDb = new ClientDbContext())
|
||||
{
|
||||
globalDb.Database.EnsureCreated();
|
||||
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
|
||||
if (entry == null)
|
||||
{
|
||||
userGuid = Guid.NewGuid().ToString();
|
||||
entry = new UserDirectoryEntry
|
||||
{
|
||||
Username = Username,
|
||||
UserGuid = userGuid,
|
||||
RemoteServerUrl = ServerUrl
|
||||
};
|
||||
globalDb.UserDirectory.Add(entry);
|
||||
globalDb.SaveChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
userGuid = entry.UserGuid;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive key from password for SQLCipher
|
||||
var encryptionKey = _cryptoService.DeriveKeyFromPassword(LoginPassword, Username);
|
||||
|
||||
// Configure ClientDbContext to open the encrypted database
|
||||
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
|
||||
ClientDbContext.ActiveDbPassword = encryptionKey;
|
||||
|
||||
// Check if this database file is newly created / has 0 nodes
|
||||
bool databaseExistsAndEmpty = false;
|
||||
using (var userDb = new ClientDbContext())
|
||||
{
|
||||
userDb.Database.EnsureCreated();
|
||||
databaseExistsAndEmpty = !userDb.Nodes.Any();
|
||||
}
|
||||
|
||||
// Check if the global local database has nodes to migrate
|
||||
bool hasLocalNotes = false;
|
||||
using (var globalDb = new ClientDbContext())
|
||||
{
|
||||
// Temporarily switch back to read from global DB
|
||||
ClientDbContext.ActiveDbName = "snote_client.db";
|
||||
ClientDbContext.ActiveDbPassword = null;
|
||||
|
||||
hasLocalNotes = globalDb.Nodes.Any(n => !n.IsDeleted);
|
||||
}
|
||||
|
||||
// Restore active DB configuration to user secure database
|
||||
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
|
||||
ClientDbContext.ActiveDbPassword = encryptionKey;
|
||||
|
||||
IsLoggedIn = true;
|
||||
IsOnline = true;
|
||||
SyncStatus = "Logged in successfully.";
|
||||
|
||||
// Save config to user secure database so it works online
|
||||
using (var userDb = new ClientDbContext())
|
||||
{
|
||||
userDb.Database.EnsureCreated();
|
||||
userDb.Configs.Add(_config);
|
||||
userDb.SaveChanges();
|
||||
}
|
||||
|
||||
// First-time login migration check
|
||||
if (databaseExistsAndEmpty && hasLocalNotes)
|
||||
{
|
||||
IsMigrateDialogVisible = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Run Sync directly
|
||||
await TriggerSync();
|
||||
}
|
||||
|
||||
ScanLocalAccounts();
|
||||
}
|
||||
else
|
||||
{
|
||||
SyncStatus = $"Login failed: {error}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TriggerSync()
|
||||
{
|
||||
if (_config == null)
|
||||
{
|
||||
SyncStatus = "Please log in to sync.";
|
||||
return;
|
||||
}
|
||||
|
||||
SyncStatus = "Synchronizing notes...";
|
||||
var (success, error) = await _syncService.SyncNodesAsync(_config);
|
||||
if (success)
|
||||
{
|
||||
SyncStatus = $"Synchronized at {DateTime.Now:HH:mm:ss}";
|
||||
RefreshNotebookTree();
|
||||
}
|
||||
else
|
||||
{
|
||||
SyncStatus = $"Sync failed: {error}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DisconnectOnline()
|
||||
{
|
||||
IsLoggedIn = false;
|
||||
IsOnline = false;
|
||||
LoginPassword = "";
|
||||
_config = null;
|
||||
|
||||
// Switch back to unencrypted default local DB snote_client.db
|
||||
ClientDbContext.ActiveDbName = "snote_client.db";
|
||||
ClientDbContext.ActiveDbPassword = null;
|
||||
|
||||
SyncStatus = "Switched to local offline mode.";
|
||||
RefreshNotebookTree();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="300"
|
||||
x:Class="SNote.Views.AcceptShareDialog"
|
||||
x:DataType="vm:MainViewModel">
|
||||
|
||||
<Border Background="#1D2D44"
|
||||
CornerRadius="12"
|
||||
Padding="20"
|
||||
Width="380" Height="280"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
BoxShadow="0 4 20 #000000">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Add Shared Node"
|
||||
FontSize="16" FontWeight="Bold"
|
||||
Foreground="#F0F3F4"
|
||||
Margin="0,0,0,15"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="10">
|
||||
<!-- Shared Item GUID -->
|
||||
<TextBlock Text="Shared Item GUID" Foreground="#A9B2C3" FontSize="12"/>
|
||||
<TextBox Text="{Binding AcceptGuid}" Watermark="Enter shared item GUID..." CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
|
||||
|
||||
<!-- Passkey k1 -->
|
||||
<TextBlock Text="Sharing Passkey (k1)" Foreground="#A9B2C3" FontSize="12" Margin="0,5,0,0"/>
|
||||
<TextBox Text="{Binding AcceptPasskey}" Watermark="Enter passkey k1..." CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right" Margin="0,15,0,0">
|
||||
<Button Content="Cancel"
|
||||
Click="Cancel_Click"/>
|
||||
<Button Content="Add Note"
|
||||
Command="{Binding ConfirmAcceptShareCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,23 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using SNote.ViewModels;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class AcceptShareDialog : UserControl
|
||||
{
|
||||
public AcceptShareDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void Cancel_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.IsAcceptDialogVisible = false;
|
||||
vm.AcceptGuid = "";
|
||||
vm.AcceptPasskey = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200"
|
||||
x:Class="SNote.Views.CreateFolderDialog"
|
||||
x:DataType="vm:MainViewModel">
|
||||
|
||||
<Border Background="#1D2D44"
|
||||
CornerRadius="12"
|
||||
Padding="20"
|
||||
Width="360" Height="180"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
BoxShadow="0 4 20 #000000">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Create New Folder"
|
||||
FontSize="16" FontWeight="Bold"
|
||||
Foreground="#F0F3F4"
|
||||
Margin="0,0,0,15"/>
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Text="{Binding NewFolderName}"
|
||||
Watermark="Enter folder name..."
|
||||
CornerRadius="6"
|
||||
Background="#0D1B2A"
|
||||
BorderThickness="0"
|
||||
Foreground="White"
|
||||
Padding="8"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,15,0,0">
|
||||
<Button Content="Cancel"
|
||||
Click="Cancel_Click"/>
|
||||
<Button Content="Create"
|
||||
Command="{Binding ConfirmCreateFolderCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,22 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using SNote.ViewModels;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class CreateFolderDialog : UserControl
|
||||
{
|
||||
public CreateFolderDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void Cancel_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.IsCreateFolderDialogVisible = false;
|
||||
vm.NewFolderName = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:models="using:SNote.Models"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="40"
|
||||
x:Class="SNote.Views.FolderItemView"
|
||||
x:CompileBindings="False">
|
||||
|
||||
<Button Background="Transparent"
|
||||
BorderThickness="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Padding="8,6"
|
||||
CornerRadius="6"
|
||||
Command="{Binding $parent[UserControl].DataContext.SelectNodeCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Link to..."
|
||||
Command="{Binding $parent[UserControl].DataContext.OpenLinkDialogCommand}" />
|
||||
<MenuItem Header="Share folder..."
|
||||
Command="{Binding $parent[UserControl].DataContext.OpenShareDialogCommand}" />
|
||||
<MenuItem Header="Delete"
|
||||
Foreground="Red"
|
||||
Command="{Binding $parent[UserControl].DataContext.DeleteNodeCommand}" />
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<!-- Sleek Folder Icon -->
|
||||
<Path Data="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"
|
||||
Fill="#E9C46A"
|
||||
Width="16" Height="16"
|
||||
Stretch="Uniform"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#E0E1DD"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class FolderItemView : UserControl
|
||||
{
|
||||
public FolderItemView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="300"
|
||||
x:Class="SNote.Views.LinkFolderDialog"
|
||||
x:DataType="vm:MainViewModel">
|
||||
|
||||
<Border Background="#1D2D44"
|
||||
CornerRadius="12"
|
||||
Padding="20"
|
||||
Width="360" Height="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
BoxShadow="0 4 20 #000000">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Link Node to Folder"
|
||||
FontSize="16" FontWeight="Bold"
|
||||
Foreground="#F0F3F4"
|
||||
Margin="0,0,0,15"/>
|
||||
|
||||
<!-- Folders list selector -->
|
||||
<ScrollViewer Grid.Row="1" Margin="0,0,0,15">
|
||||
<ItemsControl ItemsSource="{Binding AllFoldersList}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Content="{Binding Name}"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="10,8"
|
||||
CornerRadius="6"
|
||||
Foreground="#E0E1DD"
|
||||
Command="{Binding $parent[UserControl].DataContext.LinkNodeToFolderCommand, DataType={x:Type vm:MainViewModel}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Cancel action -->
|
||||
<Button Grid.Row="2"
|
||||
Content="Cancel"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding CloseLinkDialogCommand}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class LinkFolderDialog : UserControl
|
||||
{
|
||||
public LinkFolderDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,264 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
xmlns:views="using:SNote.Views"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
|
||||
x:Class="SNote.Views.MainView"
|
||||
x:DataType="vm:MainViewModel">
|
||||
<Design.DataContext>
|
||||
<!-- This only sets the DataContext for the previewer in an IDE,
|
||||
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
||||
<vm:MainViewModel />
|
||||
</Design.DataContext>
|
||||
x:DataType="vm:MainViewModel"
|
||||
Background="#0D1B2A"
|
||||
SizeChanged="UserControl_SizeChanged">
|
||||
|
||||
<Grid>
|
||||
<!-- Main Application Split Layout -->
|
||||
<Grid ColumnDefinitions="Auto,*" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
|
||||
<!-- 1. LEFT COLUMN: SIDEBAR -->
|
||||
<Border Grid.Column="0"
|
||||
Width="280"
|
||||
Background="#1B263B"
|
||||
BorderBrush="#3E5C76"
|
||||
BorderThickness="0,0,1,0"
|
||||
IsVisible="{Binding IsSidebarVisible}">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="15">
|
||||
|
||||
<!-- Header / Brand -->
|
||||
<StackPanel Grid.Row="0" Spacing="5" Margin="0,0,0,15">
|
||||
<TextBlock Text="SNote Notebook"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
Foreground="#F0F3F4"/>
|
||||
<Border Background="#415A77" CornerRadius="4" Padding="6,3" HorizontalAlignment="Left">
|
||||
<TextBlock Text="{Binding SyncStatus}"
|
||||
FontSize="10"
|
||||
Foreground="#E0E1DD"
|
||||
FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Server Login / Settings Panel -->
|
||||
<Border Grid.Row="1"
|
||||
Background="#0D1B2A"
|
||||
CornerRadius="8"
|
||||
Padding="12"
|
||||
Margin="0,0,0,15"
|
||||
BorderBrush="#3E5C76"
|
||||
BorderThickness="1">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Connection & Sync" FontSize="11" FontWeight="Bold" Foreground="#A9B2C3"/>
|
||||
|
||||
<!-- If Logged In -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsLoggedIn}">
|
||||
<TextBlock Text="{Binding Username}" FontWeight="SemiBold" Foreground="#2A9D8F" FontSize="13"/>
|
||||
<TextBlock Text="{Binding ServerUrl}" Foreground="#8D99AE" FontSize="10" TextTrimming="CharacterEllipsis"/>
|
||||
<Grid ColumnDefinitions="*,*">
|
||||
<Button Grid.Column="0" Content="Sync Now" Command="{Binding TriggerSyncCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Disconnect" Margin="5,0,0,0" Command="{Binding DisconnectOnlineCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- If Logged Out (Local Mode) -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding !IsLoggedIn}">
|
||||
<!-- Local Accounts Selector -->
|
||||
<StackPanel Spacing="4" Margin="0,0,0,5" IsVisible="{Binding LocalAccounts.Count}">
|
||||
<TextBlock Text="Select local account:" FontSize="10" Foreground="#8D99AE" FontWeight="SemiBold"/>
|
||||
<ComboBox ItemsSource="{Binding LocalAccounts}"
|
||||
SelectedItem="{Binding SelectedLocalAccount}"
|
||||
PlaceholderText="Select user..."
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="#1B263B"
|
||||
BorderThickness="0"
|
||||
CornerRadius="4"/>
|
||||
</StackPanel>
|
||||
<TextBox Text="{Binding Username}" PlaceholderText="Username" Background="#1B263B" Foreground="White" BorderThickness="0" CornerRadius="4" Padding="6,4" FontSize="11"/>
|
||||
<TextBox Text="{Binding LoginPassword}" PasswordChar="*" PlaceholderText="Password" Background="#1B263B" Foreground="White" BorderThickness="0" CornerRadius="4" Padding="6,4" FontSize="11"/>
|
||||
<TextBox Text="{Binding ServerUrl}" PlaceholderText="Server Url" Background="#1B263B" Foreground="White" BorderThickness="0" CornerRadius="4" Padding="6,4" FontSize="11"/>
|
||||
<Grid ColumnDefinitions="*,*">
|
||||
<Button Grid.Column="0" Content="Login" Command="{Binding TriggerLoginCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Register" Margin="5,0,0,0" Command="{Binding TriggerRegisterCommand}" CommandParameter="{Binding LoginPassword}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Tree structured view of folders and notes -->
|
||||
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="10">
|
||||
<!-- Header actions -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="0,0,0,5">
|
||||
<TextBlock Grid.Column="0" Text="Notebook Tree" FontSize="12" FontWeight="Bold" Foreground="#A9B2C3" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="+ Folder" Command="{Binding OpenCreateFolderDialogCommand}"/>
|
||||
<Button Grid.Column="2" Content="+ Note" Margin="5,0,0,0" Command="{Binding CreateNoteCommand}" CommandParameter="New Note"/>
|
||||
<Button Grid.Column="3" Content="Accept" Margin="5,0,0,0" Command="{Binding OpenAcceptDialogCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Recursive TreeView -->
|
||||
<TreeView Grid.Row="1"
|
||||
ItemsSource="{Binding NotebookTree}"
|
||||
SelectedItem="{Binding SelectedNotebookItem, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
x:CompileBindings="False">
|
||||
<TreeView.DataTemplates>
|
||||
<TreeDataTemplate DataType="{x:Type vm:NotebookItem}" ItemsSource="{Binding Children}">
|
||||
<!-- Tree Node container with context menu -->
|
||||
<Grid ColumnDefinitions="Auto,*" Background="Transparent">
|
||||
<Grid.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Rename" Command="{Binding $parent[TreeView].DataContext.OpenRenameDialogCommand}" CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="Link to..." Command="{Binding $parent[TreeView].DataContext.OpenLinkDialogCommand}"/>
|
||||
<MenuItem Header="Share..." Command="{Binding $parent[TreeView].DataContext.OpenShareDialogCommand}"/>
|
||||
<MenuItem Header="Delete" Command="{Binding $parent[TreeView].DataContext.DeleteNodeCommand}"/>
|
||||
</ContextMenu>
|
||||
</Grid.ContextMenu>
|
||||
|
||||
<!-- Icon indicator based on node type -->
|
||||
<Path Grid.Column="0"
|
||||
Data="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"
|
||||
Fill="#E9C46A"
|
||||
Width="14" Height="14"
|
||||
Stretch="Uniform"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"
|
||||
IsVisible="{Binding IsFolder}"/>
|
||||
|
||||
<Path Grid.Column="0"
|
||||
Data="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"
|
||||
Fill="#2A9D8F"
|
||||
Width="14" Height="14"
|
||||
Stretch="Uniform"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"
|
||||
IsVisible="{Binding IsNote}"/>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Name}"
|
||||
Foreground="#E0E1DD"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.DataTemplates>
|
||||
</TreeView>
|
||||
</Grid>
|
||||
|
||||
<!-- Bottom offline footer -->
|
||||
<TextBlock Grid.Row="3"
|
||||
Text="SNote v1.0.0"
|
||||
FontSize="10"
|
||||
Foreground="#8D99AE"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,10,0,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 2. RIGHT COLUMN: EDITOR PANEL -->
|
||||
<Border Grid.Column="1"
|
||||
Background="#0D1B2A"
|
||||
IsVisible="{Binding IsContentVisible}">
|
||||
|
||||
<Grid>
|
||||
<!-- When NO note is selected -->
|
||||
<Grid IsVisible="{Binding SelectedNode, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<StackPanel Spacing="15" HorizontalAlignment="Center">
|
||||
<Path Data="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2z"
|
||||
Fill="#415A77"
|
||||
Width="64" Height="64"
|
||||
HorizontalAlignment="Center"
|
||||
Stretch="Uniform"/>
|
||||
<TextBlock Text="Select a note or folder to start"
|
||||
FontSize="15"
|
||||
Foreground="#A9B2C3"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Note Editor Panel -->
|
||||
<Grid IsVisible="{Binding SelectedNode, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
RowDefinitions="Auto,*" Margin="20">
|
||||
|
||||
<!-- Editor Title bar -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto,Auto" Margin="0,0,0,15">
|
||||
<!-- Back button (Narrow mode only) -->
|
||||
<Button Grid.Column="0"
|
||||
Content="< Back"
|
||||
Margin="0,0,10,0"
|
||||
IsVisible="{Binding IsNarrowMode}"
|
||||
Command="{Binding GoBackCommand}"/>
|
||||
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding NoteName}"
|
||||
FontSize="16"
|
||||
FontWeight="Bold"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="White"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Save Note action -->
|
||||
<Button Grid.Column="2"
|
||||
Content="Save Note"
|
||||
Margin="5,0"
|
||||
Command="{Binding SaveActiveNoteCommand}"/>
|
||||
|
||||
<!-- Share action -->
|
||||
<Button Grid.Column="3"
|
||||
Content="Share..."
|
||||
Margin="5,0"
|
||||
Command="{Binding OpenShareDialogCommand}"/>
|
||||
|
||||
<!-- Delete action -->
|
||||
<Button Grid.Column="4"
|
||||
Content="Delete"
|
||||
Command="{Binding DeleteNodeCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Text editor body -->
|
||||
<TextBox Grid.Row="1"
|
||||
Text="{Binding NoteContent}"
|
||||
PlaceholderText="Type your notes here..."
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
Background="#1B263B"
|
||||
BorderThickness="0"
|
||||
CornerRadius="10"
|
||||
Padding="15"
|
||||
Foreground="White"
|
||||
FontSize="14"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- 3. DIALOG OVERLAY COVERS -->
|
||||
<!-- Create Folder Dialog -->
|
||||
<Grid Background="#B0000000" IsVisible="{Binding IsCreateFolderDialogVisible}">
|
||||
<views:CreateFolderDialog DataContext="{Binding}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Link to Folder Dialog -->
|
||||
<Grid Background="#B0000000" IsVisible="{Binding IsLinkDialogVisible}">
|
||||
<views:LinkFolderDialog DataContext="{Binding}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Share Node Dialog -->
|
||||
<Grid Background="#B0000000" IsVisible="{Binding IsShareDialogVisible}">
|
||||
<views:ShareNodeDialog DataContext="{Binding}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Accept Share Dialog -->
|
||||
<Grid Background="#B0000000" IsVisible="{Binding IsAcceptDialogVisible}">
|
||||
<views:AcceptShareDialog DataContext="{Binding}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Rename Item Dialog -->
|
||||
<Grid Background="#B0000000" IsVisible="{Binding IsRenameDialogVisible}">
|
||||
<views:RenameDialog DataContext="{Binding}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Migrate Notes Dialog -->
|
||||
<Grid Background="#B0000000" IsVisible="{Binding IsMigrateDialogVisible}">
|
||||
<views:MigrateNotesDialog DataContext="{Binding}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using SNote.ViewModels;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
@@ -8,4 +9,12 @@ public partial class MainView : UserControl
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void UserControl_SizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.UpdateWidthMode(e.NewSize.Width);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="220"
|
||||
x:Class="SNote.Views.MigrateNotesDialog"
|
||||
x:DataType="vm:MainViewModel">
|
||||
|
||||
<Border Background="#1D2D44"
|
||||
CornerRadius="12"
|
||||
Padding="20"
|
||||
Width="400" Height="190"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
BoxShadow="0 4 20 #000000">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Migrate Local Notes?"
|
||||
FontSize="16" FontWeight="Bold"
|
||||
Foreground="#F0F3F4"
|
||||
Margin="0,0,0,10"/>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="It looks like this is the first time you are logging into this remote account, and you have offline notes saved locally. Do you want to migrate your local notes to this remote account?"
|
||||
FontSize="12"
|
||||
Foreground="#C2C5BB"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,15,0,0">
|
||||
<Button Content="Keep Empty"
|
||||
Command="{Binding CancelMigrationCommand}"/>
|
||||
<Button Content="Migrate Notes"
|
||||
Command="{Binding ConfirmMigrationCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class MigrateNotesDialog : UserControl
|
||||
{
|
||||
public MigrateNotesDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:models="using:SNote.Models"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="40"
|
||||
x:Class="SNote.Views.NoteItemView"
|
||||
x:CompileBindings="False">
|
||||
|
||||
<Button Background="Transparent"
|
||||
BorderThickness="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Padding="8,6"
|
||||
CornerRadius="6"
|
||||
Command="{Binding $parent[UserControl].DataContext.SelectNodeCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Link to..."
|
||||
Command="{Binding $parent[UserControl].DataContext.OpenLinkDialogCommand}" />
|
||||
<MenuItem Header="Share note..."
|
||||
Command="{Binding $parent[UserControl].DataContext.OpenShareDialogCommand}" />
|
||||
<MenuItem Header="Delete"
|
||||
Foreground="Red"
|
||||
Command="{Binding $parent[UserControl].DataContext.DeleteNodeCommand}" />
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<!-- Sleek Note Icon -->
|
||||
<Path Data="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"
|
||||
Fill="#2A9D8F"
|
||||
Width="16" Height="16"
|
||||
Stretch="Uniform"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Text="{Binding Name}"
|
||||
Foreground="#E0E1DD"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class NoteItemView : UserControl
|
||||
{
|
||||
public NoteItemView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200"
|
||||
x:Class="SNote.Views.RenameDialog"
|
||||
x:DataType="vm:MainViewModel">
|
||||
|
||||
<Border Background="#1D2D44"
|
||||
CornerRadius="12"
|
||||
Padding="20"
|
||||
Width="360" Height="180"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
BoxShadow="0 4 20 #000000">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Rename Item"
|
||||
FontSize="16" FontWeight="Bold"
|
||||
Foreground="#F0F3F4"
|
||||
Margin="0,0,0,15"/>
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Text="{Binding RenameTargetName}"
|
||||
Watermark="Enter new name..."
|
||||
CornerRadius="6"
|
||||
Background="#0D1B2A"
|
||||
BorderThickness="0"
|
||||
Foreground="White"
|
||||
Padding="8"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,15,0,0">
|
||||
<Button Content="Cancel"
|
||||
Command="{Binding CancelRenameCommand}"/>
|
||||
<Button Content="Rename"
|
||||
Command="{Binding ConfirmRenameCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class RenameDialog : UserControl
|
||||
{
|
||||
public RenameDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:SNote.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="350"
|
||||
x:Class="SNote.Views.ShareNodeDialog"
|
||||
x:DataType="vm:MainViewModel">
|
||||
|
||||
<Border Background="#1D2D44"
|
||||
CornerRadius="12"
|
||||
Padding="20"
|
||||
Width="380" Height="360"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
BoxShadow="0 4 20 #000000">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Cryptographic Share Node"
|
||||
FontSize="16" FontWeight="Bold"
|
||||
Foreground="#F0F3F4"
|
||||
Margin="0,0,0,15"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="10">
|
||||
<!-- Recipient Username -->
|
||||
<TextBlock Text="Recipient Username" Foreground="#A9B2C3" FontSize="12"/>
|
||||
<TextBox x:Name="RecipientInput" Watermark="Enter recipient username..." CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
|
||||
|
||||
<!-- Passkey k1 -->
|
||||
<TextBlock Text="Sharing Passkey (k1)" Foreground="#A9B2C3" FontSize="12" Margin="0,5,0,0"/>
|
||||
<TextBox Text="{Binding SharePasskey}" CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
|
||||
|
||||
<!-- Display Shared GUID after share completes -->
|
||||
<TextBlock Text="Shared Item GUID (Copy to recipient)" Foreground="#A9B2C3" FontSize="12" Margin="0,5,0,0"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding ShareGuid, Mode=OneWay}" IsReadOnly="True" CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="#2A9D8F" Padding="8"/>
|
||||
<Button Grid.Column="1" Content="Copy" Margin="5,0,0,0" Click="CopyGuid_Click"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right" Margin="0,15,0,0">
|
||||
<Button Content="Close"
|
||||
Click="Close_Click"/>
|
||||
<Button Content="Confirm Share"
|
||||
Command="{Binding ConfirmShareNodeCommand}"
|
||||
CommandParameter="{Binding #RecipientInput.Text}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Input.Platform;
|
||||
using SNote.ViewModels;
|
||||
|
||||
namespace SNote.Views;
|
||||
|
||||
public partial class ShareNodeDialog : UserControl
|
||||
{
|
||||
public ShareNodeDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void CopyGuid_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm && !string.IsNullOrEmpty(vm.ShareGuid))
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.Clipboard != null)
|
||||
{
|
||||
await topLevel.Clipboard.SetTextAsync(vm.ShareGuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Close_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.IsShareDialogVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user