forked from Kieran/void.cat
Page file list response
Standardize api calls
This commit is contained in:
parent
49c6aaa249
commit
f5e3b47311
@ -16,11 +16,11 @@ public class AdminController : Controller
|
||||
_fileStore = fileStore;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[HttpPost]
|
||||
[Route("file")]
|
||||
public IAsyncEnumerable<PublicVoidFile> ListFiles()
|
||||
public Task<RenderedResults<PublicVoidFile>> ListFiles([FromBody] PagedRequest request)
|
||||
{
|
||||
return _fileStore.ListFiles();
|
||||
return _fileStore.ListFiles(request).GetResults();
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
|
@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
@ -26,6 +27,12 @@ public class AuthController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryValidateModel(req))
|
||||
{
|
||||
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
|
||||
return new(null, error);
|
||||
}
|
||||
|
||||
var user = await _manager.Login(req.Username, req.Password);
|
||||
var token = CreateToken(user);
|
||||
var tokenWriter = new JwtSecurityTokenHandler();
|
||||
@ -43,6 +50,12 @@ public class AuthController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryValidateModel(req))
|
||||
{
|
||||
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
|
||||
return new(null, error);
|
||||
}
|
||||
|
||||
var newUser = await _manager.Register(req.Username, req.Password);
|
||||
var token = CreateToken(newUser);
|
||||
var tokenWriter = new JwtSecurityTokenHandler();
|
||||
@ -72,7 +85,22 @@ public class AuthController : Controller
|
||||
}
|
||||
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
public class LoginRequest
|
||||
{
|
||||
public LoginRequest(string username, string password)
|
||||
{
|
||||
Username = username;
|
||||
Password = password;
|
||||
}
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(6)]
|
||||
public string Password { get; init; }
|
||||
}
|
||||
|
||||
public record LoginResponse(string? Jwt, string? Error = null);
|
||||
}
|
||||
|
@ -24,7 +24,8 @@ namespace VoidCat.Controllers
|
||||
var bw = await _statsReporter.GetBandwidth();
|
||||
var bytes = 0UL;
|
||||
var count = 0;
|
||||
await foreach (var vf in _fileStore.ListFiles())
|
||||
var files = _fileStore.ListFiles(new(0, Int32.MaxValue));
|
||||
await foreach (var vf in files.Results)
|
||||
{
|
||||
bytes += vf.Metadata?.Size ?? 0;
|
||||
count++;
|
||||
|
46
VoidCat/Model/PagedResult.cs
Normal file
46
VoidCat/Model/PagedResult.cs
Normal file
@ -0,0 +1,46 @@
|
||||
namespace VoidCat.Model;
|
||||
|
||||
public abstract class PagedResult
|
||||
{
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int Pages => TotalResults / PageSize;
|
||||
public int TotalResults { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PagedResult<T> : PagedResult
|
||||
{
|
||||
public IAsyncEnumerable<T> Results { get; init; }
|
||||
|
||||
public async Task<RenderedResults<T>> GetResults()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Page = Page,
|
||||
PageSize = PageSize,
|
||||
TotalResults = TotalResults,
|
||||
Results = await Results.ToListAsync()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RenderedResults<T> : PagedResult
|
||||
{
|
||||
public IList<T> Results { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PagedRequest(int Page, int PageSize, PagedSortBy SortBy = PagedSortBy.Name, PageSortOrder SortOrder = PageSortOrder.Asc);
|
||||
|
||||
public enum PagedSortBy : byte
|
||||
{
|
||||
Name,
|
||||
Date,
|
||||
Size,
|
||||
Id
|
||||
}
|
||||
|
||||
public enum PageSortOrder : byte
|
||||
{
|
||||
Asc,
|
||||
Dsc
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
namespace VoidCat.Services.Abstractions;
|
||||
namespace VoidCat.Model;
|
||||
|
||||
public sealed record RangeRequest(long? TotalSize, long? Start, long? End)
|
||||
{
|
@ -10,7 +10,7 @@ public interface IFileStore
|
||||
|
||||
ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts);
|
||||
|
||||
IAsyncEnumerable<PublicVoidFile> ListFiles();
|
||||
PagedResult<PublicVoidFile> ListFiles(PagedRequest request);
|
||||
|
||||
ValueTask DeleteFile(Guid id);
|
||||
}
|
@ -34,12 +34,17 @@ public class LocalDiskFileStore : IFileStore
|
||||
|
||||
public async ValueTask<PublicVoidFile?> Get(Guid id)
|
||||
{
|
||||
var meta = _metadataStore.GetPublic(id);
|
||||
var paywall = _paywallStore.GetConfig(id);
|
||||
var bandwidth = _statsReporter.GetBandwidth(id);
|
||||
await Task.WhenAll(meta.AsTask(), paywall.AsTask(), bandwidth.AsTask());
|
||||
|
||||
return new()
|
||||
{
|
||||
Id = id,
|
||||
Metadata = await _metadataStore.GetPublic(id),
|
||||
Paywall = await _paywallStore.GetConfig(id),
|
||||
Bandwidth = await _statsReporter.GetBandwidth(id)
|
||||
Metadata = meta.Result,
|
||||
Paywall = paywall.Result,
|
||||
Bandwidth = bandwidth.Result
|
||||
};
|
||||
}
|
||||
|
||||
@ -114,20 +119,45 @@ public class LocalDiskFileStore : IFileStore
|
||||
};
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<PublicVoidFile> ListFiles()
|
||||
public PagedResult<PublicVoidFile> ListFiles(PagedRequest request)
|
||||
{
|
||||
foreach (var fe in Directory.EnumerateFiles(_settings.DataDirectory))
|
||||
var files = Directory.EnumerateFiles(_settings.DataDirectory)
|
||||
.Where(a => !Path.HasExtension(a));
|
||||
files = (request.SortBy, request.SortOrder) switch
|
||||
{
|
||||
var filename = Path.GetFileNameWithoutExtension(fe);
|
||||
if (Path.HasExtension(fe)) continue; // real file does not have extension
|
||||
if (!Guid.TryParse(filename, out var id)) continue;
|
||||
(PagedSortBy.Id, PageSortOrder.Asc) => files.OrderBy(a =>
|
||||
Guid.TryParse(Path.GetFileNameWithoutExtension(a), out var g) ? g : Guid.Empty),
|
||||
(PagedSortBy.Id, PageSortOrder.Dsc) => files.OrderByDescending(a =>
|
||||
Guid.TryParse(Path.GetFileNameWithoutExtension(a), out var g) ? g : Guid.Empty),
|
||||
(PagedSortBy.Name, PageSortOrder.Asc) => files.OrderBy(Path.GetFileNameWithoutExtension),
|
||||
(PagedSortBy.Name, PageSortOrder.Dsc) => files.OrderByDescending(Path.GetFileNameWithoutExtension),
|
||||
(PagedSortBy.Size, PageSortOrder.Asc) => files.OrderBy(a => new FileInfo(a).Length),
|
||||
(PagedSortBy.Size, PageSortOrder.Dsc) => files.OrderByDescending(a => new FileInfo(a).Length),
|
||||
(PagedSortBy.Date, PageSortOrder.Asc) => files.OrderBy(File.GetCreationTimeUtc),
|
||||
(PagedSortBy.Date, PageSortOrder.Dsc) => files.OrderByDescending(File.GetCreationTimeUtc),
|
||||
_ => files
|
||||
};
|
||||
|
||||
var vf = await Get(id);
|
||||
if (vf != default)
|
||||
async IAsyncEnumerable<PublicVoidFile> EnumeratePage(IEnumerable<string> page)
|
||||
{
|
||||
foreach (var file in page)
|
||||
{
|
||||
yield return vf;
|
||||
if (!Guid.TryParse(Path.GetFileNameWithoutExtension(file), out var gid)) continue;
|
||||
var loaded = await Get(gid);
|
||||
if (loaded != default)
|
||||
{
|
||||
yield return loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize,
|
||||
TotalResults = files.Count(),
|
||||
Results = EnumeratePage(files.Skip(request.PageSize * request.Page).Take(request.PageSize))
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DeleteFile(Guid id)
|
||||
|
@ -18,6 +18,7 @@
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="5.0.2" />
|
||||
<PackageReference Include="Seq.Extensions.Logging" Version="6.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.5.27-prerelease" />
|
||||
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Don't publish the SPA source files, but do show them in the project files list -->
|
||||
|
@ -1,41 +1,45 @@
|
||||
import moment from "moment";
|
||||
import {Link} from "react-router-dom";
|
||||
import {useSelector} from "react-redux";
|
||||
import {useDispatch, useSelector} from "react-redux";
|
||||
import {useEffect, useState} from "react";
|
||||
import {FormatBytes} from "../Util";
|
||||
|
||||
import "./FileList.css";
|
||||
import {AdminApi} from "../Api";
|
||||
import {logout} from "../LoginState";
|
||||
import {PagedSortBy, PageSortOrder} from "../Const";
|
||||
|
||||
export function FileList(props) {
|
||||
const auth = useSelector((state) => state.login.jwt);
|
||||
const dispatch = useDispatch();
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
async function loadFileList() {
|
||||
let req = await fetch("/admin/file", {
|
||||
headers: {
|
||||
"authorization": `Bearer ${auth}`
|
||||
}
|
||||
});
|
||||
let pageReq = {
|
||||
page: 0,
|
||||
pageSize: 20,
|
||||
sortBy: PagedSortBy.Date,
|
||||
sortOrder: PageSortOrder.Dsc
|
||||
};
|
||||
let req = await AdminApi.fileList(auth, pageReq);
|
||||
if (req.ok) {
|
||||
setFiles(await req.json());
|
||||
} else if (req.status === 401) {
|
||||
dispatch(logout());
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(e, id) {
|
||||
e.target.disabled = true;
|
||||
|
||||
let req = await fetch(`/admin/file/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"authorization": `Bearer ${auth}`
|
||||
if (window.confirm(`Are you sure you want to delete: ${id}?`)) {
|
||||
let req = await AdminApi.deleteFile(auth, id);
|
||||
if (req.ok) {
|
||||
setFiles([
|
||||
...files.filter(a => a.id !== id)
|
||||
]);
|
||||
} else {
|
||||
alert("Failed to delete file!");
|
||||
}
|
||||
});
|
||||
if (req.ok) {
|
||||
setFiles([
|
||||
...files.filter(a => a.id !== id)
|
||||
]);
|
||||
} else {
|
||||
alert("Failed to delete file!");
|
||||
}
|
||||
e.target.disabled = false;
|
||||
}
|
||||
|
32
VoidCat/spa/src/Api.js
Normal file
32
VoidCat/spa/src/Api.js
Normal file
@ -0,0 +1,32 @@
|
||||
async function getJson(method, url, auth, body) {
|
||||
let headers = {
|
||||
"Accept": "application/json"
|
||||
};
|
||||
if (auth) {
|
||||
headers["Authorization"] = `Bearer ${auth}`;
|
||||
}
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export const AdminApi = {
|
||||
fileList: (auth, pageReq) => getJson("POST", "/admin/file", auth, pageReq),
|
||||
deleteFile: (auth, id) => getJson("DELETE", `/admin/file/${id}`, auth)
|
||||
}
|
||||
|
||||
export const Api = {
|
||||
stats: () => getJson("GET", "/stats"),
|
||||
fileInfo: (id) => getJson("GET", `/upload/${id}`),
|
||||
setPaywallConfig: (id, cfg) => getJson("POST", `/upload/${id}/paywall`, undefined, cfg),
|
||||
createOrder: (id) => getJson("GET", `/upload/${id}/paywall`),
|
||||
getOrder: (file, order) => getJson("GET", `/upload/${file}/paywall/${order}`),
|
||||
login: (username, password) => getJson("POST", `/auth/login`, undefined, {username, password}),
|
||||
register: (username, password) => getJson("POST", `/auth/register`, undefined, {username, password})
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
@ -48,3 +48,15 @@ export const PaywallOrderState = {
|
||||
Paid: 1,
|
||||
Expired: 2
|
||||
}
|
||||
|
||||
export const PagedSortBy = {
|
||||
Name: 0,
|
||||
Date: 1,
|
||||
Size: 2,
|
||||
Id: 3
|
||||
}
|
||||
|
||||
export const PageSortOrder = {
|
||||
Asc: 0,
|
||||
Dsc: 1
|
||||
}
|
@ -10,7 +10,7 @@ export function Countdown(props) {
|
||||
let now = new Date().getTime();
|
||||
let seconds = (to - now) / 1000.0;
|
||||
setTime(Math.max(0, seconds));
|
||||
if(seconds <= 0 && typeof onEnded === "function") {
|
||||
if (seconds <= 0 && typeof onEnded === "function") {
|
||||
onEnded();
|
||||
}
|
||||
}, 100);
|
||||
|
@ -18,7 +18,7 @@ export function Dropzone(props) {
|
||||
|
||||
function renderUploads() {
|
||||
let fElm = [];
|
||||
for(let f of files) {
|
||||
for (let f of files) {
|
||||
fElm.push(<FileUpload file={f} key={f.name}/>);
|
||||
}
|
||||
return (
|
||||
|
@ -1,8 +1,9 @@
|
||||
import {useState} from "react";
|
||||
|
||||
import "./FileEdit.css";
|
||||
import {StrikePaywallConfig} from "./StrikePaywallConfig";
|
||||
import {NoPaywallConfig} from "./NoPaywallConfig";
|
||||
import {Api} from "./Api";
|
||||
import "./FileEdit.css";
|
||||
|
||||
export function FileEdit(props) {
|
||||
const file = props.file;
|
||||
@ -14,13 +15,7 @@ export function FileEdit(props) {
|
||||
}
|
||||
|
||||
async function saveConfig(cfg) {
|
||||
let req = await fetch(`/upload/${file.id}/paywall`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(cfg),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
let req = await Api.setPaywallConfig(file.id, cfg);
|
||||
return req.ok;
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {ConstName, FormatCurrency} from "./Util";
|
||||
import {PaywallCurrencies, PaywallServices} from "./Const";
|
||||
import {FormatCurrency} from "./Util";
|
||||
import {PaywallServices} from "./Const";
|
||||
import {useState} from "react";
|
||||
import {LightningPaywall} from "./LightningPaywall";
|
||||
import {Api} from "./Api";
|
||||
|
||||
export function FilePaywall(props) {
|
||||
const file = props.file;
|
||||
@ -13,7 +14,7 @@ export function FilePaywall(props) {
|
||||
|
||||
async function fetchOrder(e) {
|
||||
e.target.disabled = true;
|
||||
let req = await fetch(`/upload/${file.id}/paywall`);
|
||||
let req = await Api.createOrder(file.id);
|
||||
if (req.ok) {
|
||||
setOrder(await req.json());
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import {TextPreview} from "./TextPreview";
|
||||
import "./FilePreview.css";
|
||||
import {FileEdit} from "./FileEdit";
|
||||
import {FilePaywall} from "./FilePaywall";
|
||||
import {Api} from "./Api";
|
||||
|
||||
export function FilePreview() {
|
||||
const params = useParams();
|
||||
@ -13,7 +14,7 @@ export function FilePreview() {
|
||||
const [link, setLink] = useState("#");
|
||||
|
||||
async function loadInfo() {
|
||||
let req = await fetch(`/upload/${params.id}`);
|
||||
let req = await Api.fileInfo(params.id);
|
||||
if (req.ok) {
|
||||
let info = await req.json();
|
||||
setInfo(info);
|
||||
|
@ -1,9 +1,9 @@
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import "./FileUpload.css";
|
||||
import {buf2hex, ConstName, FormatBytes} from "./Util";
|
||||
import {RateCalculator} from "./RateCalculator";
|
||||
|
||||
import "./FileUpload.css";
|
||||
|
||||
const UploadState = {
|
||||
NotStarted: 0,
|
||||
Starting: 1,
|
||||
@ -171,7 +171,7 @@ export function FileUpload(props) {
|
||||
function getChallengeElement() {
|
||||
let elm = document.createElement("iframe");
|
||||
elm.contentWindow.document.write(challenge);
|
||||
return <div dangerouslySetInnerHTML={{ __html: elm.outerHTML }}/>;
|
||||
return <div dangerouslySetInnerHTML={{__html: elm.outerHTML}}/>;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -3,12 +3,13 @@ import FeatherIcon from "feather-icons-react";
|
||||
import {FormatBytes} from "./Util";
|
||||
|
||||
import "./GlobalStats.css";
|
||||
import {Api} from "./Api";
|
||||
|
||||
export function GlobalStats(props) {
|
||||
let [stats, setStats] = useState();
|
||||
|
||||
async function loadStats() {
|
||||
let req = await fetch("/stats");
|
||||
let req = await Api.stats();
|
||||
if (req.ok) {
|
||||
setStats(await req.json());
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import QRCode from "qrcode.react";
|
||||
import {Countdown} from "./Countdown";
|
||||
import {useEffect} from "react";
|
||||
|
||||
import {Countdown} from "./Countdown";
|
||||
import {PaywallOrderState} from "./Const";
|
||||
import {Api} from "./Api";
|
||||
|
||||
export function LightningPaywall(props) {
|
||||
const file = props.file;
|
||||
@ -16,7 +18,7 @@ export function LightningPaywall(props) {
|
||||
}
|
||||
|
||||
async function checkStatus() {
|
||||
let req = await fetch(`/upload/${file.id}/paywall/${order.id}`);
|
||||
let req = await Api.getOrder(file.id, order.id);
|
||||
if (req.ok) {
|
||||
let order = await req.json();
|
||||
|
||||
|
@ -3,34 +3,28 @@ import {useDispatch} from "react-redux";
|
||||
import {setAuth} from "./LoginState";
|
||||
|
||||
import "./Login.css";
|
||||
import {Api} from "./Api";
|
||||
|
||||
export function Login(props) {
|
||||
export function Login() {
|
||||
const [username, setUsername] = useState();
|
||||
const [password, setPassword] = useState();
|
||||
const [error, setError] = useState();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
async function login(e, url) {
|
||||
async function login(e, fnLogin) {
|
||||
e.target.disabled = true;
|
||||
setError(null);
|
||||
|
||||
let req = await fetch(`/auth/${url}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username, password
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
}
|
||||
});
|
||||
let req = await fnLogin(username, password);
|
||||
if (req.ok) {
|
||||
let rsp = await req.json();
|
||||
if(rsp.jwt) {
|
||||
if (rsp.jwt) {
|
||||
dispatch(setAuth(rsp.jwt));
|
||||
} else {
|
||||
setError(rsp.error);
|
||||
}
|
||||
}
|
||||
|
||||
e.target.disabled = false;
|
||||
}
|
||||
|
||||
@ -39,12 +33,12 @@ export function Login(props) {
|
||||
<h2>Login</h2>
|
||||
<dl>
|
||||
<dt>Username:</dt>
|
||||
<dd><input onChange={(e) => setUsername(e.target.value)}/></dd>
|
||||
<dd><input onChange={(e) => setUsername(e.target.value)} placeholder="user@example.com"/></dd>
|
||||
<dt>Password:</dt>
|
||||
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
|
||||
</dl>
|
||||
<button onClick={(e) => login(e, "login")}>Login</button>
|
||||
<button onClick={(e) => login(e, "register")}>Register</button>
|
||||
<button onClick={(e) => login(e, Api.login)}>Login</button>
|
||||
<button onClick={(e) => login(e, Api.register)}>Register</button>
|
||||
{error ? <div className="error-msg">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
@ -19,5 +19,5 @@ export const LoginState = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
export const { setAuth, logout } = LoginState.actions;
|
||||
export const {setAuth, logout} = LoginState.actions;
|
||||
export default LoginState.reducer;
|
@ -23,8 +23,8 @@ export class RateCalculator {
|
||||
let total = 0.0;
|
||||
|
||||
let windowStart = new Date().getTime() - (s * 1000);
|
||||
for(let r of this.reports) {
|
||||
if(r.time >= windowStart) {
|
||||
for (let r of this.reports) {
|
||||
if (r.time >= windowStart) {
|
||||
total += r.amount;
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ export function TextPreview(props) {
|
||||
|
||||
async function getContent(link) {
|
||||
let req = await fetch(link);
|
||||
if(req.ok) {
|
||||
if (req.ok) {
|
||||
setContent(await req.text());
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import * as Const from "./Const";
|
||||
|
||||
/**
|
||||
* Formats bytes into binary notation
|
||||
* @param {number} b - The value in bytes
|
||||
@ -31,8 +32,8 @@ export function buf2hex(buffer) {
|
||||
}
|
||||
|
||||
export function ConstName(type, val) {
|
||||
for(let [k, v] of Object.entries(type)) {
|
||||
if(v === val) {
|
||||
for (let [k, v] of Object.entries(type)) {
|
||||
if (v === val) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
@ -52,7 +53,7 @@ export function FormatCurrency(value, currency) {
|
||||
})}`;
|
||||
}
|
||||
case 1:
|
||||
case "USD":{
|
||||
case "USD": {
|
||||
return value.toLocaleString(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD"
|
||||
|
Loading…
Reference in New Issue
Block a user