Add click to copy and form validation (#181)

This commit is contained in:
Leendert de Borst
2025-03-10 21:49:58 +01:00
committed by Leendert de Borst
parent 697abc6828
commit a53575b4bf
5 changed files with 253 additions and 152 deletions

View File

@@ -36,29 +36,34 @@
@if (IsAddFormVisible)
{
<div class="p-4 mb-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex justify-between items-center mb-4">
<h4 class="text-lg font-medium text-gray-900 dark:text-white">Add 2FA TOTP Code</h4>
<button @onclick="HideAddForm" type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close form</span>
</button>
</div>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">If the website offers or requires 2FA for your account (such as Google Authenticator), you can use AliasVault instead to generate the codes for you.</p>
<div class="mb-4">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
<input type="text" id="name" @bind="NewTotpCode.Name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" placeholder="Service Name" required>
</div>
<div class="mb-4">
<label for="secretKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (32 characters)</label>
<input type="text" id="secretKey" @bind="NewTotpCode.SecretKey" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" placeholder="Enter 32-character code" required>
</div>
<div class="flex justify-end">
<button type="button" @onclick="AddTotpCode" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Save
</button>
</div>
<EditForm Model="@NewTotpCode" OnValidSubmit="AddTotpCode">
<DataAnnotationsValidator />
<div class="flex justify-between items-center mb-4">
<h4 class="text-lg font-medium text-gray-900 dark:text-white">Add 2FA TOTP Code</h4>
<button @onclick="HideAddForm" type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close form</span>
</button>
</div>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">If the website offers or requires 2FA for your account (such as Google Authenticator), you can use AliasVault instead to generate the codes for you.</p>
<div class="mb-4">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name (optional)</label>
<InputText id="name" @bind-Value="NewTotpCode.Name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" />
<ValidationMessage For="@(() => NewTotpCode.Name)" class="text-red-600 dark:text-red-400 text-sm mt-1" />
</div>
<div class="mb-4">
<label for="secretKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<InputText id="secretKey" @bind-Value="NewTotpCode.SecretKey" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" placeholder="Enter secret key (manual entry)" />
<ValidationMessage For="@(() => NewTotpCode.SecretKey)" class="text-red-600 dark:text-red-400 text-sm mt-1" />
</div>
<div class="flex justify-end">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Save
</button>
</div>
</EditForm>
</div>
}
@@ -102,12 +107,6 @@
</div>
@code {
/// <summary>
/// The service name.
/// </summary>
[Parameter]
public string ServiceName { get; set; } = string.Empty;
/// <summary>
/// The list of TOTP codes.
/// </summary>
@@ -122,7 +121,7 @@
private bool IsLoading { get; set; } = true;
private bool IsAddFormVisible { get; set; } = false;
private TotpCode NewTotpCode { get; set; } = new();
private TotpCodeEdit NewTotpCode { get; set; } = new();
private Timer? _refreshTimer;
private Dictionary<string, string> _currentCodes = new();
private List<Guid> OriginalTotpCodeIds { get; set; } = [];
@@ -208,10 +207,7 @@
private void ShowAddForm()
{
NewTotpCode = new TotpCode
{
Name = ServiceName
};
NewTotpCode = new TotpCodeEdit();
IsAddFormVisible = true;
}
@@ -222,22 +218,53 @@
private async Task AddTotpCode()
{
if (string.IsNullOrWhiteSpace(NewTotpCode.Name))
{
GlobalNotificationService.AddErrorMessage("Name is required.", true);
return;
}
string secretKey = NewTotpCode.SecretKey;
if (string.IsNullOrWhiteSpace(NewTotpCode.SecretKey))
// Sanitize the secret key (remove whitespace and hyphens)
secretKey = secretKey.Replace(" ", string.Empty).Replace("-", string.Empty);
string? name = NewTotpCode.Name;
// Check if the input is a TOTP URI
if (secretKey.StartsWith("otpauth://totp/"))
{
GlobalNotificationService.AddErrorMessage("Secret key is required.", true);
return;
try
{
var uri = new Uri(secretKey);
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
// Extract the secret from query parameters
secretKey = queryParams["secret"] ?? throw new Exception("Secret not found in URI");
// If no name was provided, try to get it from the URI
if (string.IsNullOrWhiteSpace(name))
{
// The label is everything after 'totp/' and before '?'
var label = uri.AbsolutePath.TrimStart('/');
// If the label contains ':', take the part after it
name = label.Contains(':') ? label.Split(':')[1] : label;
// If there's an issuer in the query params, use it as a prefix
var issuer = queryParams["issuer"];
if (!string.IsNullOrWhiteSpace(issuer))
{
name = $"{issuer}: {name}";
}
NewTotpCode.Name = name;
}
NewTotpCode.SecretKey = secretKey;
}
catch (Exception)
{
GlobalNotificationService.AddErrorMessage("Invalid TOTP URI format. Please check and try again.", true);
return;
}
}
try
{
// Validate the secret key by trying to generate a code
TotpGenerator.GenerateTotpCode(NewTotpCode.SecretKey);
TotpGenerator.GenerateTotpCode(secretKey);
}
catch (Exception)
{
@@ -246,14 +273,8 @@
}
// Create a new TOTP code in memory
var newTotpCode = new TotpCode
{
Id = Guid.Empty,
Name = NewTotpCode.Name,
SecretKey = NewTotpCode.SecretKey,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
var newTotpCode = NewTotpCode.ToEntity();
newTotpCode.Name = name ?? "Authenticator";
// Add to the list
TotpCodeList.Add(newTotpCode);

View File

@@ -1,5 +1,7 @@
@inherits ComponentBase
@inject TotpCodeService TotpCodeService
@inject ClipboardCopyService ClipboardCopyService
@inject JsInteropService JsInteropService
@implements IDisposable
@using TotpGenerator
@@ -32,8 +34,19 @@
</div>
<div class="flex items-center gap-4">
<div class="flex flex-col items-end">
<div class="text-2xl font-bold text-gray-900 dark:text-white">@GetTotpCode(totpCode.SecretKey)</div>
<div class="text-xs text-gray-500 dark:text-gray-400">@GetRemainingSeconds()s</div>
<div class="text-2xl font-bold cursor-pointer text-gray-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 transition-colors" @onclick="() => CopyToClipboard(totpCode)">
@GetTotpCode(totpCode.SecretKey)
</div>
<div class="text-xs">
@if (IsCopied(totpCode.Id.ToString()))
{
<span class="text-green-600 dark:text-green-400">Copied!</span>
}
else
{
<span class="text-gray-500 dark:text-gray-400">@GetRemainingSeconds()s</span>
}
</div>
</div>
<div class="w-1.5 h-8 bg-gray-200 rounded-full dark:bg-gray-600">
<div class="bg-blue-600 rounded-full transition-all" style="height: @(GetRemainingPercentage())%; width: 100%"></div>
@@ -118,4 +131,22 @@
// Invert the percentage so it counts down instead of up
return (int)(((30.0 - remaining) / 30.0) * 100);
}
private async Task CopyToClipboard(TotpCode totpCode)
{
var code = GetTotpCode(totpCode.SecretKey);
await JsInteropService.CopyToClipboard(code);
ClipboardCopyService.SetCopied(totpCode.Id.ToString());
StateHasChanged();
// After 2 seconds, reset the copied state
await Task.Delay(2000);
if (ClipboardCopyService.GetCopiedId() == totpCode.Id.ToString())
{
ClipboardCopyService.SetCopied(string.Empty);
}
StateHasChanged();
}
private bool IsCopied(string code) => ClipboardCopyService.GetCopiedId() == code;
}

View File

@@ -0,0 +1,66 @@
//-----------------------------------------------------------------------
// <copyright file="TotpCodeEdit.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models;
using System;
using System.ComponentModel.DataAnnotations;
using AliasClientDb;
/// <summary>
/// Credential edit model.
/// </summary>
public sealed class TotpCodeEdit
{
/// <summary>
/// Gets or sets the Id of the TOTP code.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the TOTP code.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the secret key of the TOTP code.
/// </summary>
[Required(ErrorMessage = "Secret key is required")]
public string SecretKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the created at date of the TOTP code.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the updated at date of the TOTP code.
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the TOTP code is deleted.
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// Converts the edit model to a TotpCode entity.
/// </summary>
/// <returns>The TotpCode entity.</returns>
public TotpCode ToEntity()
{
return new TotpCode
{
Id = Id,
Name = Name ?? string.Empty,
SecretKey = SecretKey,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsDeleted = IsDeleted,
};
}
}

View File

@@ -47,13 +47,13 @@ else
@if (EditMode && Id.HasValue)
{
<div class="col-span-1 md:col-span-1 lg:col-span-1">
<TotpCodes ServiceName="@Obj.ServiceName" TotpCodeList="@Obj.TotpCodes" TotpCodesChanged="HandleTotpCodesChanged" />
<TotpCodes TotpCodeList="@Obj.TotpCodes" TotpCodesChanged="HandleTotpCodesChanged" />
</div>
}
else
{
<div class="col-span-1 md:col-span-1 lg:col-span-1">
<TotpCodes ServiceName="@Obj.ServiceName" TotpCodeList="@Obj.TotpCodes" TotpCodesChanged="HandleTotpCodesChanged" />
<TotpCodes TotpCodeList="@Obj.TotpCodes" TotpCodesChanged="HandleTotpCodesChanged" />
</div>
}

View File

@@ -674,10 +674,6 @@ video {
top: 40px;
}
.top-0 {
top: 0px;
}
.-z-10 {
z-index: -10;
}
@@ -809,6 +805,10 @@ video {
margin-inline-start: 0.5rem;
}
.ms-auto {
margin-inline-start: auto;
}
.mt-1 {
margin-top: 0.25rem;
}
@@ -837,10 +837,6 @@ video {
margin-top: 2rem;
}
.ms-auto {
margin-inline-start: auto;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
@@ -944,10 +940,6 @@ video {
max-height: 90vh;
}
.max-h-full {
max-height: 100%;
}
.min-h-\[250px\] {
min-height: 250px;
}
@@ -956,6 +948,14 @@ video {
min-height: 100vh;
}
.w-1 {
width: 0.25rem;
}
.w-1\.5 {
width: 0.375rem;
}
.w-1\/2 {
width: 50%;
}
@@ -1016,14 +1016,6 @@ video {
width: 100%;
}
.w-1 {
width: 0.25rem;
}
.w-1\.5 {
width: 0.375rem;
}
.min-w-0 {
min-width: 0px;
}
@@ -1072,11 +1064,6 @@ video {
transform-origin: top right;
}
.rotate-180 {
--tw-rotate: 180deg;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.transform {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
@@ -1346,11 +1333,6 @@ video {
border-bottom-right-radius: 0.5rem;
}
.rounded-t {
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
}
.border {
border-width: 1px;
}
@@ -1458,6 +1440,16 @@ video {
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-blue-700 {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -1573,6 +1565,10 @@ video {
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.bg-transparent {
background-color: transparent;
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@@ -1583,25 +1579,6 @@ video {
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
}
.bg-black {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-blue-700 {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.bg-transparent {
background-color: transparent;
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -2078,6 +2055,12 @@ video {
transition-duration: 150ms;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -2090,12 +2073,6 @@ video {
transition-duration: 150ms;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.duration-150 {
transition-duration: 150ms;
}
@@ -2128,6 +2105,11 @@ video {
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-blue-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
}
.hover\:bg-gray-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -2208,11 +2190,6 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-blue-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
}
.hover\:from-primary-600:hover {
--tw-gradient-from: #d68338 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(214 131 56 / 0) var(--tw-gradient-to-position);
@@ -2268,16 +2245,16 @@ video {
color: rgb(185 28 28 / var(--tw-text-opacity));
}
.hover\:text-white:hover {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.hover\:text-red-800:hover {
--tw-text-opacity: 1;
color: rgb(153 27 27 / var(--tw-text-opacity));
}
.hover\:text-white:hover {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -2408,6 +2385,11 @@ video {
border-color: rgb(156 163 175 / var(--tw-border-opacity));
}
.dark\:border-gray-500:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(107 114 128 / var(--tw-border-opacity));
}
.dark\:border-gray-600:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
@@ -2448,9 +2430,9 @@ video {
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
.dark\:border-gray-500:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(107 114 128 / var(--tw-border-opacity));
.dark\:bg-blue-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.dark\:bg-blue-800:is(.dark *) {
@@ -2551,11 +2533,6 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-blue-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -2635,6 +2612,11 @@ video {
color: rgb(248 113 113 / var(--tw-text-opacity));
}
.dark\:text-red-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.dark\:text-white:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -2650,11 +2632,6 @@ video {
color: rgb(250 204 21 / var(--tw-text-opacity));
}
.dark\:text-red-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
@@ -2674,6 +2651,16 @@ video {
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-gray-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
@@ -2714,16 +2701,6 @@ video {
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.dark\:hover\:from-primary-500:hover:is(.dark *) {
--tw-gradient-from: #f49541 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(244 149 65 / 0) var(--tw-gradient-to-position);
@@ -2739,6 +2716,11 @@ video {
color: rgb(191 219 254 / var(--tw-text-opacity));
}
.dark\:hover\:text-blue-400:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
}
.dark\:hover\:text-gray-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
@@ -2749,21 +2731,26 @@ video {
color: rgb(248 185 99 / var(--tw-text-opacity));
}
.dark\:hover\:text-primary-400:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(246 167 82 / var(--tw-text-opacity));
}
.dark\:hover\:text-primary-500:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(244 149 65 / var(--tw-text-opacity));
}
.dark\:hover\:text-white:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:text-red-400:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
}
.dark\:hover\:text-white:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
@@ -2979,10 +2966,6 @@ video {
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.md\:p-5 {
padding: 1.25rem;
}
}
@media (min-width: 1024px) {