mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 14:22:40 +01:00
Wallet transactions: Add label manager (#4796)
* Wallet transactions: Add label manager * Update BTCPayServer/Views/UIWallets/WalletTransactions.cshtml Co-authored-by: Andrew Camilleri <evilkukka@gmail.com> * Add rich label info * Fixes * support labels in wallet send * add labels to tx info page * Remove noscript parts * Allow click on transaction label info * update psbt info labelstyling * revert red pixel fix as it broke all --------- Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
This commit is contained in:
parent
8635fcfe84
commit
95f3e429b4
18 changed files with 453 additions and 227 deletions
|
@ -1374,11 +1374,11 @@ namespace BTCPayServer.Tests
|
||||||
// Can add a label?
|
// Can add a label?
|
||||||
await TestUtils.EventuallyAsync(async () =>
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
{
|
{
|
||||||
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).Click();
|
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).Click();
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("test-label" + Keys.Enter);
|
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("test-label" + Keys.Enter);
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("label2" + Keys.Enter);
|
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("label2" + Keys.Enter);
|
||||||
});
|
});
|
||||||
|
|
||||||
TestUtils.Eventually(() =>
|
TestUtils.Eventually(() =>
|
||||||
|
|
|
@ -19,7 +19,7 @@ namespace BTCPayServer
|
||||||
var bg = ColorTranslator.FromHtml(bgColor);
|
var bg = ColorTranslator.FromHtml(bgColor);
|
||||||
int bgDelta = Convert.ToInt32((bg.R * 0.299) + (bg.G * 0.587) + (bg.B * 0.114));
|
int bgDelta = Convert.ToInt32((bg.R * 0.299) + (bg.G * 0.587) + (bg.B * 0.114));
|
||||||
Color color = (255 - bgDelta < nThreshold) ? Color.Black : Color.White;
|
Color color = (255 - bgDelta < nThreshold) ? Color.Black : Color.White;
|
||||||
return ColorTranslator.ToHtml(color);
|
return ColorTranslator.ToHtml(color).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
|
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
|
||||||
public static readonly ColorPalette Default = new ColorPalette(new string[] {
|
public static readonly ColorPalette Default = new ColorPalette(new string[] {
|
||||||
|
|
|
@ -1,104 +1,22 @@
|
||||||
@using NBitcoin.DataEncoders
|
@using NBitcoin.DataEncoders
|
||||||
@using NBitcoin
|
@using NBitcoin
|
||||||
@using BTCPayServer.Abstractions.TagHelpers
|
|
||||||
@model BTCPayServer.Components.LabelManager.LabelViewModel
|
@model BTCPayServer.Components.LabelManager.LabelViewModel
|
||||||
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
|
|
||||||
@{
|
@{
|
||||||
var commonCall = Model.ObjectId.Type + Model.ObjectId.Id;
|
|
||||||
var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
|
var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
|
||||||
|
var fetchUrl = Url.Action("GetLabels", "UIWallets", new {
|
||||||
|
walletId = Model.WalletObjectId.WalletId,
|
||||||
|
excludeTypes = Safe.Json(Model.ExcludeTypes)
|
||||||
|
});
|
||||||
|
var updateUrl = Model.AutoUpdate? Url.Action("UpdateLabels", "UIWallets", new {
|
||||||
|
walletId = Model.WalletObjectId.WalletId
|
||||||
|
}): string.Empty;
|
||||||
}
|
}
|
||||||
|
<input id="@elementId" placeholder="Select labels" autocomplete="off" value="@string.Join(",", Model.SelectedLabels)"
|
||||||
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" rel="stylesheet">
|
class="only-for-js form-control label-manager ts-wrapper @(Model.DisplayInline ? "ts-inline" : "")"
|
||||||
<script src="~/vendor/tom-select/tom-select.complete.min.js"></script>
|
data-fetch-url="@fetchUrl"
|
||||||
<script>
|
data-update-url="@updateUrl"
|
||||||
const updateUrl = @Safe.Json(Url.Action("UpdateLabels", "UIWallets", new {
|
data-wallet-id="@Model.WalletObjectId.WalletId"
|
||||||
Model.ObjectId.WalletId
|
data-wallet-object-id="@Model.WalletObjectId.Id"
|
||||||
}));
|
data-wallet-object-type="@Model.WalletObjectId.Type"
|
||||||
const getUrl = @Safe.Json(@Url.Action("GetLabels", "UIWallets", new {
|
data-select-element="@Model.SelectElement"
|
||||||
walletId = Model.ObjectId.WalletId,
|
data-labels='@Safe.Json(Model.RichLabelInfo)' />
|
||||||
excludeTypes = true
|
|
||||||
}));
|
|
||||||
const commonCall = @Safe.Json(commonCall);
|
|
||||||
const elementId = @Safe.Json(elementId);
|
|
||||||
if (!window[commonCall]) {
|
|
||||||
window[commonCall] = fetch(getUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
}).then(response => {
|
|
||||||
return response.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
|
||||||
const element = document.querySelector(`#${elementId}`);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
const labelsFetchTask = await window[commonCall];
|
|
||||||
const config = {
|
|
||||||
create: true,
|
|
||||||
items: @Safe.Json(Model.SelectedLabels),
|
|
||||||
options: labelsFetchTask,
|
|
||||||
valueField: "label",
|
|
||||||
labelField: "label",
|
|
||||||
searchField: "label",
|
|
||||||
allowEmptyOption: false,
|
|
||||||
closeAfterSelect: false,
|
|
||||||
persist: true,
|
|
||||||
render: {
|
|
||||||
option: function(data, escape) {
|
|
||||||
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
|
|
||||||
},
|
|
||||||
item: function(data, escape) {
|
|
||||||
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onItemAdd: (val) => {
|
|
||||||
window[commonCall] = window[commonCall].then(labels => {
|
|
||||||
return [...labels, { label: val }]
|
|
||||||
});
|
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent(`${commonCall}-option-added`, {
|
|
||||||
detail: val
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
onChange: async (values) => {
|
|
||||||
select.lock();
|
|
||||||
try {
|
|
||||||
const response = await fetch(updateUrl, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
address: @Safe.Json(Model.ObjectId.Id),
|
|
||||||
labels: select.items
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not OK');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('There has been a problem with your fetch operation:', error);
|
|
||||||
} finally {
|
|
||||||
select.unlock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const select = new TomSelect(element, config);
|
|
||||||
|
|
||||||
document.addEventListener(`${commonCall}-option-added`, evt => {
|
|
||||||
if (!(evt.detail in select.options)) {
|
|
||||||
select.addOption({
|
|
||||||
label: evt.detail
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<input id="@elementId" placeholder="Select labels to associate with this object" autocomplete="off" class="form-control label-manager"/>
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
@ -5,14 +7,25 @@ namespace BTCPayServer.Components.LabelManager
|
||||||
{
|
{
|
||||||
public class LabelManager : ViewComponent
|
public class LabelManager : ViewComponent
|
||||||
{
|
{
|
||||||
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels)
|
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels, bool excludeTypes = true, bool displayInline = false, Dictionary<string, RichLabelInfo> richLabelInfo = null, bool autoUpdate = true, string selectElement = null)
|
||||||
{
|
{
|
||||||
var vm = new LabelViewModel
|
var vm = new LabelViewModel
|
||||||
{
|
{
|
||||||
ObjectId = walletObjectId,
|
ExcludeTypes = excludeTypes,
|
||||||
SelectedLabels = selectedLabels
|
WalletObjectId = walletObjectId,
|
||||||
|
SelectedLabels = selectedLabels?? Array.Empty<string>(),
|
||||||
|
DisplayInline = displayInline,
|
||||||
|
RichLabelInfo = richLabelInfo,
|
||||||
|
AutoUpdate = autoUpdate,
|
||||||
|
SelectElement = selectElement
|
||||||
};
|
};
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class RichLabelInfo
|
||||||
|
{
|
||||||
|
public string Link { get; set; }
|
||||||
|
public string Tooltip { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
|
|
||||||
namespace BTCPayServer.Components.LabelManager
|
namespace BTCPayServer.Components.LabelManager
|
||||||
|
@ -5,6 +6,11 @@ namespace BTCPayServer.Components.LabelManager
|
||||||
public class LabelViewModel
|
public class LabelViewModel
|
||||||
{
|
{
|
||||||
public string[] SelectedLabels { get; set; }
|
public string[] SelectedLabels { get; set; }
|
||||||
public WalletObjectId ObjectId { get; set; }
|
public WalletObjectId WalletObjectId { get; set; }
|
||||||
|
public bool ExcludeTypes { get; set; }
|
||||||
|
public bool DisplayInline { get; set; }
|
||||||
|
public Dictionary<string, RichLabelInfo> RichLabelInfo { get; set; }
|
||||||
|
public bool AutoUpdate { get; set; }
|
||||||
|
public string SelectElement { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
using BTCPayServer.BIP78.Sender;
|
using BTCPayServer.BIP78.Sender;
|
||||||
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.HostedServices;
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.ModelBinders;
|
using BTCPayServer.ModelBinders;
|
||||||
using BTCPayServer.Models;
|
using BTCPayServer.Models;
|
||||||
|
@ -266,7 +267,7 @@ namespace BTCPayServer.Controllers
|
||||||
ModelState.Remove(nameof(vm.PSBT));
|
ModelState.Remove(nameof(vm.PSBT));
|
||||||
ModelState.Remove(nameof(vm.FileName));
|
ModelState.Remove(nameof(vm.FileName));
|
||||||
ModelState.Remove(nameof(vm.UploadedPSBTFile));
|
ModelState.Remove(nameof(vm.UploadedPSBTFile));
|
||||||
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
|
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
|
||||||
return View("WalletPSBTDecoded", vm);
|
return View("WalletPSBTDecoded", vm);
|
||||||
|
|
||||||
case "save-psbt":
|
case "save-psbt":
|
||||||
|
@ -320,7 +321,7 @@ namespace BTCPayServer.Controllers
|
||||||
return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token);
|
return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
|
private async Task FetchTransactionDetails(WalletId walletId, DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
|
||||||
{
|
{
|
||||||
var psbtObject = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork);
|
var psbtObject = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork);
|
||||||
if (!psbtObject.IsAllFinalized())
|
if (!psbtObject.IsAllFinalized())
|
||||||
|
@ -371,17 +372,29 @@ namespace BTCPayServer.Controllers
|
||||||
vm.Positive = balanceChange >= Money.Zero;
|
vm.Positive = balanceChange >= Money.Zero;
|
||||||
}
|
}
|
||||||
vm.Inputs = new List<WalletPSBTReadyViewModel.InputViewModel>();
|
vm.Inputs = new List<WalletPSBTReadyViewModel.InputViewModel>();
|
||||||
|
var inputToObjects = new Dictionary<uint, ObjectTypeId[]>();
|
||||||
|
var outputToObjects = new Dictionary<string, ObjectTypeId>();
|
||||||
foreach (var input in psbtObject.Inputs)
|
foreach (var input in psbtObject.Inputs)
|
||||||
{
|
{
|
||||||
var inputVm = new WalletPSBTReadyViewModel.InputViewModel();
|
var inputVm = new WalletPSBTReadyViewModel.InputViewModel();
|
||||||
vm.Inputs.Add(inputVm);
|
vm.Inputs.Add(inputVm);
|
||||||
|
var txOut = input.GetTxOut();
|
||||||
var mine = input.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any();
|
var mine = input.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any();
|
||||||
var balanceChange2 = input.GetTxOut()?.Value ?? Money.Zero;
|
var balanceChange2 = txOut?.Value ?? Money.Zero;
|
||||||
if (mine)
|
if (mine)
|
||||||
balanceChange2 = -balanceChange2;
|
balanceChange2 = -balanceChange2;
|
||||||
inputVm.BalanceChange = ValueToString(balanceChange2, network);
|
inputVm.BalanceChange = ValueToString(balanceChange2, network);
|
||||||
inputVm.Positive = balanceChange2 >= Money.Zero;
|
inputVm.Positive = balanceChange2 >= Money.Zero;
|
||||||
inputVm.Index = (int)input.Index;
|
inputVm.Index = (int)input.Index;
|
||||||
|
|
||||||
|
var walletObjectIds = new List<ObjectTypeId>();
|
||||||
|
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Utxo, input.PrevOut.ToString()));
|
||||||
|
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Tx, input.PrevOut.Hash.ToString()));
|
||||||
|
var address = txOut?.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
|
||||||
|
if(address != null)
|
||||||
|
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Address, address));
|
||||||
|
inputToObjects.Add(input.Index, walletObjectIds.ToArray());
|
||||||
|
|
||||||
}
|
}
|
||||||
vm.Destinations = new List<WalletPSBTReadyViewModel.DestinationViewModel>();
|
vm.Destinations = new List<WalletPSBTReadyViewModel.DestinationViewModel>();
|
||||||
foreach (var output in psbtObject.Outputs)
|
foreach (var output in psbtObject.Outputs)
|
||||||
|
@ -395,6 +408,10 @@ namespace BTCPayServer.Controllers
|
||||||
dest.Balance = ValueToString(balanceChange2, network);
|
dest.Balance = ValueToString(balanceChange2, network);
|
||||||
dest.Positive = balanceChange2 >= Money.Zero;
|
dest.Positive = balanceChange2 >= Money.Zero;
|
||||||
dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString();
|
dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString();
|
||||||
|
var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
|
||||||
|
if(address != null)
|
||||||
|
outputToObjects.Add(dest.Destination, new ObjectTypeId(WalletObjectData.Types.Address, address));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (psbtObject.TryGetFee(out var fee))
|
if (psbtObject.TryGetFee(out var fee))
|
||||||
|
@ -420,6 +437,38 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
vm.SetErrors(errors);
|
vm.SetErrors(errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var combinedTypeIds = inputToObjects.Values.SelectMany(ids => ids).Concat(outputToObjects.Values)
|
||||||
|
.DistinctBy(id => $"{id.Type}:{id.Id}").ToArray();
|
||||||
|
|
||||||
|
var labelInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, combinedTypeIds);
|
||||||
|
foreach (KeyValuePair<uint,ObjectTypeId[]> inputToObject in inputToObjects)
|
||||||
|
{
|
||||||
|
var keys = inputToObject.Value.Select(id => id.Id).ToArray();
|
||||||
|
WalletTransactionInfo ix = null;
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (!labelInfo.TryGetValue(key, out var i)) continue;
|
||||||
|
if (ix is null)
|
||||||
|
{
|
||||||
|
ix = i;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ix.Merge(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ix is null) continue;
|
||||||
|
var input = vm.Inputs.First(model => model.Index == inputToObject.Key);
|
||||||
|
input.Labels = ix.LabelColors;
|
||||||
|
}
|
||||||
|
foreach (var outputToObject in outputToObjects)
|
||||||
|
{
|
||||||
|
if (!labelInfo.TryGetValue(outputToObject.Value.Id, out var ix)) continue;
|
||||||
|
var destination = vm.Destinations.First(model => model.Destination == outputToObject.Key);
|
||||||
|
destination.Labels = ix.LabelColors;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{walletId}/psbt/ready")]
|
[HttpPost("{walletId}/psbt/ready")]
|
||||||
|
@ -439,7 +488,7 @@ namespace BTCPayServer.Controllers
|
||||||
if (derivationSchemeSettings == null)
|
if (derivationSchemeSettings == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
|
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
|
||||||
|
|
||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
|
@ -570,7 +619,7 @@ namespace BTCPayServer.Controllers
|
||||||
BackUrl = vm.BackUrl
|
BackUrl = vm.BackUrl
|
||||||
});
|
});
|
||||||
case "decode":
|
case "decode":
|
||||||
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
|
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
|
||||||
return View("WalletPSBTDecoded", vm);
|
return View("WalletPSBTDecoded", vm);
|
||||||
default:
|
default:
|
||||||
vm.Errors.Add("Unknown command");
|
vm.Errors.Add("Unknown command");
|
||||||
|
|
|
@ -235,8 +235,7 @@ namespace BTCPayServer.Controllers
|
||||||
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
|
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
|
||||||
model.Labels.AddRange(
|
model.Labels.AddRange(
|
||||||
(await WalletRepository.GetWalletLabels(walletId))
|
(await WalletRepository.GetWalletLabels(walletId))
|
||||||
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))
|
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
|
||||||
);
|
|
||||||
|
|
||||||
if (labelFilter != null)
|
if (labelFilter != null)
|
||||||
{
|
{
|
||||||
|
@ -733,6 +732,18 @@ namespace BTCPayServer.Controllers
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return View(vm);
|
return View(vm);
|
||||||
|
|
||||||
|
foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true))
|
||||||
|
{
|
||||||
|
var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
|
||||||
|
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress.ToLowerInvariant());
|
||||||
|
var obj = await WalletRepository.GetWalletObject(walletObjectAddress);
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
await WalletRepository.EnsureWalletObject(walletObjectAddress);
|
||||||
|
}
|
||||||
|
await WalletRepository.AddWalletObjectLabels(walletObjectAddress, labels);
|
||||||
|
}
|
||||||
|
|
||||||
var derivationScheme = GetDerivationSchemeSettings(walletId);
|
var derivationScheme = GetDerivationSchemeSettings(walletId);
|
||||||
if (derivationScheme is null)
|
if (derivationScheme is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
@ -1324,18 +1335,21 @@ namespace BTCPayServer.Controllers
|
||||||
|
|
||||||
public class UpdateLabelsRequest
|
public class UpdateLabelsRequest
|
||||||
{
|
{
|
||||||
public string? Address { get; set; }
|
public string? Id { get; set; }
|
||||||
|
public string? Type { get; set; }
|
||||||
public string[]? Labels { get; set; }
|
public string[]? Labels { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{walletId}/update-labels")]
|
[HttpPost("{walletId}/update-labels")]
|
||||||
[IgnoreAntiforgeryToken]
|
[IgnoreAntiforgeryToken]
|
||||||
public async Task<IActionResult> UpdateLabels([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [FromBody] UpdateLabelsRequest request)
|
public async Task<IActionResult> UpdateLabels(
|
||||||
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
||||||
|
[FromBody] UpdateLabelsRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(request.Address) || request.Labels is null)
|
if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null)
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
|
|
||||||
var objid = new WalletObjectId(walletId, WalletObjectData.Types.Address, request.Address);
|
var objid = new WalletObjectId(walletId, request.Type, request.Id);
|
||||||
var obj = await WalletRepository.GetWalletObject(objid);
|
var obj = await WalletRepository.GetWalletObject(objid);
|
||||||
if (obj is null)
|
if (obj is null)
|
||||||
{
|
{
|
||||||
|
@ -1353,10 +1367,19 @@ namespace BTCPayServer.Controllers
|
||||||
|
|
||||||
[HttpGet("{walletId}/labels")]
|
[HttpGet("{walletId}/labels")]
|
||||||
[IgnoreAntiforgeryToken]
|
[IgnoreAntiforgeryToken]
|
||||||
public async Task<IActionResult> GetLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, bool excludeTypes)
|
public async Task<IActionResult> GetLabels(
|
||||||
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
||||||
|
bool excludeTypes,
|
||||||
|
string? type = null,
|
||||||
|
string? id = null)
|
||||||
{
|
{
|
||||||
|
var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id)
|
||||||
return Ok(( await WalletRepository.GetWalletLabels(walletId))
|
? new WalletObjectId(walletId, type, id)
|
||||||
|
: null;
|
||||||
|
var labels = walletObjectId == null
|
||||||
|
? await WalletRepository.GetWalletLabels(walletId)
|
||||||
|
: await WalletRepository.GetWalletLabels(walletObjectId);
|
||||||
|
return Ok(labels
|
||||||
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
|
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
|
||||||
.Select(tuple => new
|
.Select(tuple => new
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using BTCPayServer.Services.Labels;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Models.WalletViewModels
|
namespace BTCPayServer.Models.WalletViewModels
|
||||||
{
|
{
|
||||||
|
@ -15,10 +14,10 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
public string Link { get; set; }
|
public string Link { get; set; }
|
||||||
public bool Positive { get; set; }
|
public bool Positive { get; set; }
|
||||||
public string Balance { get; set; }
|
public string Balance { get; set; }
|
||||||
public HashSet<TransactionTagModel> Tags { get; set; } = new HashSet<TransactionTagModel>();
|
public HashSet<TransactionTagModel> Tags { get; set; } = new ();
|
||||||
}
|
}
|
||||||
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new HashSet<(string Text, string Color, string TextColor)>();
|
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new ();
|
||||||
public List<TransactionViewModel> Transactions { get; set; } = new List<TransactionViewModel>();
|
public List<TransactionViewModel> Transactions { get; set; } = new ();
|
||||||
public override int CurrentPageCount => Transactions.Count;
|
public override int CurrentPageCount => Transactions.Count;
|
||||||
public string CryptoCode { get; set; }
|
public string CryptoCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
public bool Positive { get; set; }
|
public bool Positive { get; set; }
|
||||||
public string Destination { get; set; }
|
public string Destination { get; set; }
|
||||||
public string Balance { get; set; }
|
public string Balance { get; set; }
|
||||||
|
public Dictionary<string, string> Labels { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InputViewModel
|
public class InputViewModel
|
||||||
|
@ -24,6 +25,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
public string Error { get; set; }
|
public string Error { get; set; }
|
||||||
public bool Positive { get; set; }
|
public bool Positive { get; set; }
|
||||||
public string BalanceChange { get; set; }
|
public string BalanceChange { get; set; }
|
||||||
|
public Dictionary<string, string> Labels { get; set; } = new();
|
||||||
}
|
}
|
||||||
public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error));
|
public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error));
|
||||||
public string BalanceChange { get; set; }
|
public string BalanceChange { get; set; }
|
||||||
|
|
|
@ -34,6 +34,8 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
public bool SubtractFeesFromOutput { get; set; }
|
public bool SubtractFeesFromOutput { get; set; }
|
||||||
|
|
||||||
public string PayoutId { get; set; }
|
public string PayoutId { get; set; }
|
||||||
|
|
||||||
|
public string[] Labels { get; set; } = Array.Empty<string>();
|
||||||
}
|
}
|
||||||
public decimal CurrentBalance { get; set; }
|
public decimal CurrentBalance { get; set; }
|
||||||
public decimal ImmatureBalance { get; set; }
|
public decimal ImmatureBalance { get; set; }
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Linq.Expressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
|
@ -310,20 +311,32 @@ namespace BTCPayServer.Services
|
||||||
|
|
||||||
public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId)
|
public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId)
|
||||||
{
|
{
|
||||||
await using var ctx = _ContextFactory.CreateContext();
|
return await GetWalletLabels(w =>
|
||||||
return (await
|
w.WalletId == walletId.ToString() &&
|
||||||
ctx.WalletObjects.AsNoTracking().Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label)
|
w.Type == WalletObjectData.Types.Label);
|
||||||
.ToArrayAsync())
|
|
||||||
.Select(o =>
|
|
||||||
{
|
|
||||||
if (o.Data is null)
|
|
||||||
{
|
|
||||||
return (o.Id, ColorPalette.Default.DeterministicColor(o.Id));
|
|
||||||
}
|
}
|
||||||
return (o.Id,
|
|
||||||
JObject.Parse(o.Data)["color"]?.Value<string>() ??
|
public async Task<(string Label, string Color)[]> GetWalletLabels(WalletObjectId objectId)
|
||||||
ColorPalette.Default.DeterministicColor(o.Id));
|
{
|
||||||
}).ToArray();
|
return await GetWalletLabels(w =>
|
||||||
|
w.WalletId == objectId.WalletId.ToString() &&
|
||||||
|
w.Type == objectId.Type &&
|
||||||
|
w.Id == objectId.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string Label, string Color)[]> GetWalletLabels(Expression<Func<WalletObjectData, bool>> predicate)
|
||||||
|
{
|
||||||
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
|
return (await ctx.WalletObjects.AsNoTracking().Where(predicate).ToArrayAsync())
|
||||||
|
.Select(FormatToLabel).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string Label, string Color) FormatToLabel(WalletObjectData o)
|
||||||
|
{
|
||||||
|
return o.Data is null
|
||||||
|
? (o.Id, ColorPalette.Default.DeterministicColor(o.Id))
|
||||||
|
: (o.Id,
|
||||||
|
JObject.Parse(o.Data)["color"]?.Value<string>() ?? ColorPalette.Default.DeterministicColor(o.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> RemoveWalletObjects(WalletObjectId walletObjectId)
|
public async Task<bool> RemoveWalletObjects(WalletObjectId walletObjectId)
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
@section PageHeadContent
|
@section PageHeadContent
|
||||||
{
|
{
|
||||||
<link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true"/>
|
<link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true"/>
|
||||||
|
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
|
||||||
|
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Navbar {
|
@section Navbar {
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
@inject BTCPayServer.Security.ContentSecurityPolicies csp
|
@inject BTCPayServer.Security.ContentSecurityPolicies csp
|
||||||
@using Microsoft.AspNetCore.Mvc.ModelBinding
|
@using Microsoft.AspNetCore.Mvc.ModelBinding
|
||||||
@using BTCPayServer.Controllers
|
@using BTCPayServer.Controllers
|
||||||
|
@using BTCPayServer.Services
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Components.LabelManager
|
||||||
|
@using BTCPayServer.Components.UIExtensionPoint
|
||||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||||
@model WalletSendModel
|
@model WalletSendModel
|
||||||
@{
|
@{
|
||||||
|
@ -35,6 +39,8 @@
|
||||||
border-bottom-right-radius: .2rem !important;
|
border-bottom-right-radius: .2rem !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
|
||||||
|
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section PageFootContent
|
@section PageFootContent
|
||||||
|
@ -115,6 +121,21 @@
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Labels</label>
|
||||||
|
<select asp-for="Outputs[0].Labels" class="d-none">
|
||||||
|
@foreach (var t in Model.Outputs[0].Labels)
|
||||||
|
{
|
||||||
|
<option value="@t" selected></option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<vc:label-manager
|
||||||
|
selected-labels="Model.Outputs[0].Labels"
|
||||||
|
exclude-types="true"
|
||||||
|
select-element="Outputs_0__Labels"
|
||||||
|
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Address, Model.Outputs[0].DestinationAddress)"
|
||||||
|
auto-update="false" />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -158,6 +179,21 @@
|
||||||
<label asp-for="Outputs[index].SubtractFeesFromOutput" class="form-check-label"></label>
|
<label asp-for="Outputs[index].SubtractFeesFromOutput" class="form-check-label"></label>
|
||||||
<span asp-validation-for="Outputs[index].SubtractFeesFromOutput" class="text-danger"></span>
|
<span asp-validation-for="Outputs[index].SubtractFeesFromOutput" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Labels</label>
|
||||||
|
<select asp-for="Outputs[index].Labels" class="d-none">
|
||||||
|
@foreach (var t in Model.Outputs[index].Labels)
|
||||||
|
{
|
||||||
|
<option value="@t" selected></option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<vc:label-manager
|
||||||
|
selected-labels="Model.Outputs[index].Labels"
|
||||||
|
exclude-types="true"
|
||||||
|
select-element="Outputs_@(index)__Labels"
|
||||||
|
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Address, Model.Outputs[index].DestinationAddress)"
|
||||||
|
auto-update="false" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,6 +9,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@section PageHeadContent {
|
@section PageHeadContent {
|
||||||
|
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
|
||||||
|
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.smMaxWidth {
|
.smMaxWidth {
|
||||||
max-width: 125px;
|
max-width: 125px;
|
||||||
|
@ -45,7 +47,6 @@
|
||||||
@section PageFootContent {
|
@section PageFootContent {
|
||||||
@*Without async, somehow selenium do not manage to click on links in this page*@
|
@*Without async, somehow selenium do not manage to click on links in this page*@
|
||||||
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
|
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
|
||||||
|
|
||||||
@* Custom Range Modal *@
|
@* Custom Range Modal *@
|
||||||
<script>
|
<script>
|
||||||
let observer = null;
|
let observer = null;
|
||||||
|
@ -92,7 +93,7 @@
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
$list.innerHTML += html;
|
$list.insertAdjacentHTML('beforeend', html);
|
||||||
skip = skipNext;
|
skip = skipNext;
|
||||||
|
|
||||||
if ($loadMore) {
|
if ($loadMore) {
|
||||||
|
@ -122,6 +123,7 @@
|
||||||
|
|
||||||
$indicator.classList.add('d-none');
|
$indicator.classList.add('d-none');
|
||||||
formatDateTimes(document.getElementById('switchTimeFormat').dataset.mode);
|
formatDateTimes(document.getElementById('switchTimeFormat').dataset.mode);
|
||||||
|
initLabelManagers();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
@model WalletPSBTReadyViewModel
|
@model WalletPSBTReadyViewModel
|
||||||
|
|
||||||
|
<script src="~/js/wallet/wallet-camera-scanner.js" asp-append-version="true"></script>
|
||||||
|
<script src="~/js/wallet/WalletSend.js" asp-append-version="true"></script>
|
||||||
@if (Model.CanCalculateBalance)
|
@if (Model.CanCalculateBalance)
|
||||||
{
|
{
|
||||||
<p class="lead text-center text-secondary">
|
<p class="lead text-center text-secondary">
|
||||||
|
@ -15,6 +17,7 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Index</th>
|
<th>Index</th>
|
||||||
|
<th>Labels</th>
|
||||||
<th class="text-end">Amount</th>
|
<th class="text-end">Amount</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -31,8 +34,14 @@
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<td>@input.Index</td>
|
<td>@input.Index</td>
|
||||||
|
}<td>
|
||||||
|
@foreach (var label in input.Labels)
|
||||||
|
{
|
||||||
|
<div class="transaction-label" style="--label-bg:@label.Value;--label-fg:@ColorPalette.Default.TextColor(label.Value)">@label.Key</div>
|
||||||
}
|
}
|
||||||
|
</td>
|
||||||
<td class="text-end text-@(input.Positive ? "success" : "danger")">@input.BalanceChange</td>
|
<td class="text-end text-@(input.Positive ? "success" : "danger")">@input.BalanceChange</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -45,6 +54,7 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Destination</th>
|
<th>Destination</th>
|
||||||
|
<th>Labels</th>
|
||||||
<th class="text-end">Amount</th>
|
<th class="text-end">Amount</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -53,7 +63,15 @@
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-break">@destination.Destination</td>
|
<td class="text-break">@destination.Destination</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
@foreach (var label in destination.Labels)
|
||||||
|
{
|
||||||
|
<div class="transaction-label" style="--label-bg:@label.Value;--label-fg:@ColorPalette.Default.TextColor(label.Value)">@label.Key</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="text-end text-@(destination.Positive ? "success" : "danger")">@destination.Balance</td>
|
<td class="text-end text-@(destination.Positive ? "success" : "danger")">@destination.Balance</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
|
@using BTCPayServer.Services
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Components.LabelManager
|
||||||
@model ListTransactionsViewModel
|
@model ListTransactionsViewModel
|
||||||
|
@{
|
||||||
|
var walletId = Context.GetRouteValue("walletId").ToString();
|
||||||
|
}
|
||||||
@foreach (var transaction in Model.Transactions)
|
@foreach (var transaction in Model.Transactions)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -10,33 +15,12 @@
|
||||||
@transaction.Timestamp.ToBrowserDate()
|
@transaction.Timestamp.ToBrowserDate()
|
||||||
</td>
|
</td>
|
||||||
<td class="text-start">
|
<td class="text-start">
|
||||||
@if (transaction.Tags.Any())
|
<vc:label-manager
|
||||||
{
|
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Tx, transaction.Id)"
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
selected-labels="transaction.Tags.Select(t => t.Text).ToArray()"
|
||||||
@foreach (var label in transaction.Tags)
|
rich-label-info="transaction.Tags.Where(t=> !string.IsNullOrEmpty(t.Link)).ToDictionary(t => t.Text, t => new RichLabelInfo { Link = t.Link, Tooltip = t.Tooltip })"
|
||||||
{
|
exclude-types="false"
|
||||||
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor;">
|
display-inline="true"/>
|
||||||
<a asp-route-labelFilter="@label.Text">@label.Text</a>
|
|
||||||
<form method="post" asp-action="ModifyTransaction" asp-route-walletId="@Context.GetRouteValue("walletId")">
|
|
||||||
<input type="hidden" name="transactionId" value="@transaction.Id" />
|
|
||||||
<button name="removelabel" type="submit" value="@label.Text" class="transaction-label-action">
|
|
||||||
<vc:icon symbol="remove"/>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
@if (!string.IsNullOrEmpty(label.Link))
|
|
||||||
{
|
|
||||||
<a href="@label.Link" target="_blank" rel="noreferrer noopener" class="transaction-label-info transaction-details-icon"
|
|
||||||
title="@label.Tooltip"
|
|
||||||
data-bs-html="true"
|
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-custom-class="transaction-label-tooltip">
|
|
||||||
<vc:icon symbol="info"/>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="smMaxWidth text-truncate@(transaction.IsConfirmed ? "" : " unconf")">
|
<td class="smMaxWidth text-truncate@(transaction.IsConfirmed ? "" : " unconf")">
|
||||||
<a href="@transaction.Link" target="_blank" rel="noreferrer noopener">
|
<a href="@transaction.Link" target="_blank" rel="noreferrer noopener">
|
||||||
|
@ -52,35 +36,6 @@
|
||||||
<td class="text-end text-danger">@transaction.Balance</td>
|
<td class="text-end text-danger">@transaction.Balance</td>
|
||||||
}
|
}
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="dropstart d-inline-block me-2">
|
|
||||||
<span class="fa fa-tags cursor-pointer" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<form asp-action="ModifyTransaction" method="post" style="width:260px;" asp-route-walletId="@Context.GetRouteValue("walletId")">
|
|
||||||
<input type="hidden" name="transactionId" value="@transaction.Id" />
|
|
||||||
<div class="input-group input-group-sm p-2">
|
|
||||||
<input name="addlabel" placeholder="Label name" maxlength="20" type="text" class="form-control form-control-sm" />
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm"><span class="fa fa-plus"></span></button>
|
|
||||||
</div>
|
|
||||||
@if (Model.Labels.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<div class="py-2 px-3 d-flex flex-wrap gap-2">
|
|
||||||
@foreach (var label in Model.Labels)
|
|
||||||
{
|
|
||||||
var isActive = transaction.Tags.Any(l => l.Text == label.Text);
|
|
||||||
<button name="@(isActive ? "removelabel" : "addlabelclick")" type="submit" value="@label.Text"
|
|
||||||
class="transaction-label@(isActive ? " active" : string.Empty)" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
|
|
||||||
<span>@label.Text</span>
|
|
||||||
<span class="transaction-label-action">
|
|
||||||
<vc:icon symbol="@(isActive ? "remove" : "new")"/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dropstart d-inline-block">
|
<div class="dropstart d-inline-block">
|
||||||
@if (string.IsNullOrEmpty(transaction.Comment))
|
@if (string.IsNullOrEmpty(transaction.Comment))
|
||||||
{
|
{
|
||||||
|
|
|
@ -698,23 +698,6 @@ a.store-powered-by:hover .logo-brand-dark {
|
||||||
color: var(--btcpay-brand-tertiary);
|
color: var(--btcpay-brand-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tom Select */
|
|
||||||
.ts-wrapper.form-control .ts-control {
|
|
||||||
padding: .5rem .75rem .2rem .5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-wrapper.form-control .ts-control > .item {
|
|
||||||
margin: 0 .3rem .3rem 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-wrapper.form-control .ts-control > input {
|
|
||||||
margin: 0 0 .3rem .5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-floating .ts-wrapper {
|
|
||||||
margin-top: 1.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Payment Box */
|
/* Payment Box */
|
||||||
.payment-box {
|
.payment-box {
|
||||||
--qr-size: 256px;
|
--qr-size: 256px;
|
||||||
|
@ -773,18 +756,89 @@ a.store-powered-by:hover .logo-brand-dark {
|
||||||
font-weight: var(--btcpay-font-weight-semibold);
|
font-weight: var(--btcpay-font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tom Select */
|
||||||
|
.ts-control, .ts-control input, .ts-dropdown {
|
||||||
|
color: var(--btcpay-body-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper.form-control .ts-control {
|
||||||
|
gap: .5rem;
|
||||||
|
outline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper.form-control:not(.ts-inline) .ts-control {
|
||||||
|
padding: .5rem !important;
|
||||||
|
border-radius: var(--btcpay-border-radius) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper.form-control.focus:not(.ts-inline) .ts-control {
|
||||||
|
border-color: var(--btcpay-form-border-focus);
|
||||||
|
box-shadow: 0 0 0 1px var(--btcpay-form-border-focus) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper.form-control.ts-inline,
|
||||||
|
.ts-wrapper.form-control.ts-inline .ts-control {
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating .ts-wrapper {
|
||||||
|
margin-top: 1.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating input.ts-wrapper.form-control:not(.ts-hidden-accessible) {
|
||||||
|
padding: .5rem .75rem !important;
|
||||||
|
height: 39px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating input.label-manager.form-control ~ label {
|
||||||
|
opacity: 0.65;
|
||||||
|
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
|
||||||
|
padding: .5rem .75rem !important;
|
||||||
|
height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-dropdown.dropdown-menu,
|
||||||
|
.ts-dropdown.dropdown-menu.form-control,
|
||||||
|
.ts-dropdown.dropdown-menu.form-select {
|
||||||
|
color: var(--btcpay-dropdown-color);
|
||||||
|
background: var(--btcpay-dropdown-bg);
|
||||||
|
border: var(--btcpay-dropdown-border-width) solid var(--btcpay-dropdown-border-color);
|
||||||
|
border-radius: var(--btcpay-dropdown-border-radius);
|
||||||
|
padding: var(--btcpay-dropdown-padding-y) var(--btcpay-dropdown-padding-x);
|
||||||
|
min-width: var(--btcpay-dropdown-min-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-dropdown.dropdown-menu .ts-dropdown-content {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--btcpay-space-s);
|
||||||
|
padding: 0 var(--btcpay-space-s);
|
||||||
|
}
|
||||||
|
|
||||||
/* Transaction Labels */
|
/* Transaction Labels */
|
||||||
.transaction-label {
|
.transaction-label,
|
||||||
|
.ts-dropdown.dropdown-menu .transaction-label,
|
||||||
|
.ts-dropdown.dropdown-menu .create.transaction-label,
|
||||||
|
.ts-wrapper.form-control .ts-control > .item.transaction-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--btcpay-space-xs);
|
gap: var(--btcpay-space-xs);
|
||||||
color: var(--btcpay-body-text);
|
color: var(--btcpay-body-text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--label-bg);
|
border: 1px solid var(--label-bg, var(--btcpay-neutral-300));
|
||||||
border-radius: var(--btcpay-border-radius-l);
|
border-radius: var(--btcpay-border-radius-l);
|
||||||
font-size: var(--btcpay-font-size-s);
|
font-size: var(--btcpay-font-size-s);
|
||||||
padding: var(--btcpay-space-xs) var(--btcpay-space-s);
|
margin: 0;
|
||||||
|
padding: 1px var(--btcpay-space-s);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -796,9 +850,14 @@ a.store-powered-by:hover .logo-brand-dark {
|
||||||
|
|
||||||
.transaction-label:focus,
|
.transaction-label:focus,
|
||||||
.transaction-label:hover,
|
.transaction-label:hover,
|
||||||
.transaction-label.active {
|
.transaction-label.active,
|
||||||
color: var(--label-fg);
|
.ts-dropdown.dropdown-menu .transaction-label:focus,
|
||||||
background: var(--label-bg);
|
.ts-dropdown.dropdown-menu .transaction-label:hover,
|
||||||
|
.ts-wrapper.multi .ts-control > .item.transaction-label:focus,
|
||||||
|
.ts-wrapper.multi .ts-control > .item.transaction-label:hover,
|
||||||
|
.ts-wrapper.multi .ts-control > .item.transaction-label.active {
|
||||||
|
color: var(--label-fg, var(--btcpay-body-text));
|
||||||
|
background: var(--label-bg, var(--btcpay-neutral-300));
|
||||||
}
|
}
|
||||||
|
|
||||||
.transaction-label:focus a,
|
.transaction-label:focus a,
|
||||||
|
|
|
@ -24,6 +24,133 @@ const switchTimeFormat = event => {
|
||||||
event.target.dataset.mode = mode;
|
event.target.dataset.mode = mode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function initLabelManager (elementId) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
|
||||||
|
const labelStyle = data =>
|
||||||
|
data && data.color && data.textColor
|
||||||
|
? `--label-bg:${data.color};--label-fg:${data.textColor}`
|
||||||
|
: '--label-bg:var(--btcpay-neutral-300);--label-fg:var(--btcpay-neutral-800)'
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const { fetchUrl, updateUrl, walletId, walletObjectType, walletObjectId, labels,selectElement } = element.dataset;
|
||||||
|
const commonCallId = `walletLabels-${walletId}`;
|
||||||
|
if (!window[commonCallId]) {
|
||||||
|
window[commonCallId] = fetch(fetchUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
}).then(res => res.json());
|
||||||
|
}
|
||||||
|
const selectElementI = document.getElementById(selectElement);
|
||||||
|
const items = element.value.split(',').filter(x => !!x);
|
||||||
|
const options = await window[commonCallId].then(labels => {
|
||||||
|
const newItems = items.filter(item => !labels.find(label => label.label === item));
|
||||||
|
labels = [...labels, ...newItems.map(item => ({ label: item }))];
|
||||||
|
return labels;
|
||||||
|
});
|
||||||
|
const richInfo = labels ? JSON.parse(labels) : {};
|
||||||
|
const config = {
|
||||||
|
options,
|
||||||
|
items,
|
||||||
|
valueField: "label",
|
||||||
|
labelField: "label",
|
||||||
|
searchField: "label",
|
||||||
|
create: true,
|
||||||
|
persist: true,
|
||||||
|
allowEmptyOption: false,
|
||||||
|
closeAfterSelect: false,
|
||||||
|
render: {
|
||||||
|
dropdown (){
|
||||||
|
return '<div class="dropdown-menu"></div>';
|
||||||
|
},
|
||||||
|
option_create: function(data, escape) {
|
||||||
|
return `<div class="transaction-label create" style="${labelStyle(null)}">Add <strong>${escape(data.input)}</strong>…</div>`;
|
||||||
|
},
|
||||||
|
option (data, escape) {
|
||||||
|
return `<div class="transaction-label" style="${labelStyle(data)}"><span>${escape(data.label)}</span></div>`;
|
||||||
|
},
|
||||||
|
item (data, escape) {
|
||||||
|
const info = richInfo && richInfo[data.label];
|
||||||
|
const additionalInfo = info
|
||||||
|
? `<a href="${info.link}" target="_blank" rel="noreferrer noopener" class="transaction-label-info transaction-details-icon" title="${info.tooltip}" data-bs-html="true"
|
||||||
|
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip"><svg role="img" class="icon icon-info"><use href="/img/icon-sprite.svg#info"></use></svg></a>`
|
||||||
|
: '';
|
||||||
|
const inner = `<span>${escape(data.label)}</span>${additionalInfo}`;
|
||||||
|
return `<div class="transaction-label" style="${labelStyle(data)}">${inner}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onItemAdd (val) {
|
||||||
|
window[commonCallId] = window[commonCallId].then(labels => {
|
||||||
|
return [...labels, { label: val }]
|
||||||
|
});
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent(`${commonCallId}-option-added`, {
|
||||||
|
detail: val
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async onChange (values) {
|
||||||
|
if(selectElementI){
|
||||||
|
while (selectElementI.options.length > 0) {
|
||||||
|
selectElementI.remove(0);
|
||||||
|
}
|
||||||
|
select.items.forEach((item) => {
|
||||||
|
selectElementI.add(new Option(item, item, true, true));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if(!updateUrl)
|
||||||
|
return;
|
||||||
|
select.lock();
|
||||||
|
try {
|
||||||
|
const response = await fetch(updateUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: walletObjectId,
|
||||||
|
type: walletObjectType,
|
||||||
|
labels: select.items
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not OK');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There has been a problem with your fetch operation:', error);
|
||||||
|
} finally {
|
||||||
|
select.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const select = new TomSelect(element, config);
|
||||||
|
|
||||||
|
element.parentElement.querySelectorAll('.ts-control .transaction-label a').forEach(lbl => {
|
||||||
|
lbl.addEventListener('click', e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener(`${commonCallId}-option-added`, evt => {
|
||||||
|
if (!(evt.detail in select.options)) {
|
||||||
|
select.addOption({
|
||||||
|
label: evt.detail
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initLabelManagers = () => {
|
||||||
|
// select only elements which haven't been initialized before, those without data-localized
|
||||||
|
document.querySelectorAll("input.label-manager:not(.tomselected)").forEach($el => {
|
||||||
|
initLabelManager($el.id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// sticky header
|
// sticky header
|
||||||
const stickyHeader = document.querySelector('.sticky-header-setup + .sticky-header');
|
const stickyHeader = document.querySelector('.sticky-header-setup + .sticky-header');
|
||||||
|
@ -38,6 +165,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
// localize all elements that have localizeDate class
|
// localize all elements that have localizeDate class
|
||||||
formatDateTimes();
|
formatDateTimes();
|
||||||
|
|
||||||
|
initLabelManagers();
|
||||||
|
|
||||||
function updateTimeAgo(){
|
function updateTimeAgo(){
|
||||||
var timeagoElements = $("[data-timeago-unixms]");
|
var timeagoElements = $("[data-timeago-unixms]");
|
||||||
timeagoElements.each(function () {
|
timeagoElements.each(function () {
|
||||||
|
|
Loading…
Add table
Reference in a new issue