mirror of
https://git.v0l.io/Kieran/void.cat.git
synced 2025-04-10 10:29:04 +02:00
Basic
This commit is contained in:
parent
afee82fed9
commit
60229a0c06
32
VoidCat/Controllers/DownloadController.cs
Normal file
32
VoidCat/Controllers/DownloadController.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Services;
|
||||
|
||||
namespace VoidCat.Controllers;
|
||||
|
||||
[Route("d")]
|
||||
public class DownloadController : Controller
|
||||
{
|
||||
private readonly IFileStorage _storage;
|
||||
|
||||
public DownloadController(IFileStorage storage)
|
||||
{
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("{id}")]
|
||||
public async Task DownloadFile([FromRoute] string id)
|
||||
{
|
||||
var gid = id.FromBase58Guid();
|
||||
var meta = await _storage.Get(gid);
|
||||
if (meta == null)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
Response.ContentType = meta?.Metadata?.MimeType ?? "application/octet-stream";
|
||||
await _storage.Egress(gid, Response.Body, HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Services;
|
||||
|
||||
@ -7,25 +8,44 @@ namespace VoidCat.Controllers
|
||||
[Route("upload")]
|
||||
public class UploadController : Controller
|
||||
{
|
||||
private readonly IFileIngressFactory _fileIngress;
|
||||
private readonly IStatsCollector _stats;
|
||||
private readonly IFileStorage _storage;
|
||||
|
||||
public UploadController(IStatsCollector stats, IFileIngressFactory fileIngress)
|
||||
public UploadController(IFileStorage storage)
|
||||
{
|
||||
_stats = stats;
|
||||
_fileIngress = fileIngress;
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public Task<VoidFile> UploadFile()
|
||||
[DisableRequestSizeLimit]
|
||||
public Task<InternalVoidFile> UploadFile()
|
||||
{
|
||||
return Request.HasFormContentType ?
|
||||
saveFromForm() : _fileIngress.Ingress(Request.Body);
|
||||
saveFromForm() : _storage.Ingress(Request.Body, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
private Task<VoidFile> saveFromForm()
|
||||
[HttpGet]
|
||||
[Route("{id}")]
|
||||
public Task<VoidFile?> GetInfo([FromRoute] string id)
|
||||
{
|
||||
return Task.FromResult<VoidFile>(null);
|
||||
return _storage.Get(id.FromBase58Guid());
|
||||
}
|
||||
|
||||
[HttpPatch]
|
||||
[Route("{id}")]
|
||||
public Task UpdateFileInfo([FromRoute]string id, [FromBody]UpdateFileInfoRequest request)
|
||||
{
|
||||
return _storage.UpdateInfo(new VoidFile()
|
||||
{
|
||||
Id = id.FromBase58Guid(),
|
||||
Metadata = request.Metadata
|
||||
}, request.EditSecret);
|
||||
}
|
||||
|
||||
private Task<InternalVoidFile> saveFromForm()
|
||||
{
|
||||
return Task.FromResult<InternalVoidFile>(null);
|
||||
}
|
||||
|
||||
public record UpdateFileInfoRequest([JsonConverter(typeof(Base58GuidConverter))] Guid EditSecret, VoidFileMeta Metadata);
|
||||
}
|
||||
}
|
||||
|
21
VoidCat/Model/Base58GuidConverter.cs
Normal file
21
VoidCat/Model/Base58GuidConverter.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace VoidCat.Model;
|
||||
|
||||
public class Base58GuidConverter : JsonConverter<Guid>
|
||||
{
|
||||
public override void WriteJson(JsonWriter writer, Guid value, JsonSerializer serializer)
|
||||
{
|
||||
writer.WriteValue(value.ToBase58());
|
||||
}
|
||||
|
||||
public override Guid ReadJson(JsonReader reader, Type objectType, Guid existingValue, bool hasExistingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.TokenType == JsonToken.String && existingValue == Guid.Empty)
|
||||
{
|
||||
return (reader.Value as string)?.FromBase58Guid() ?? existingValue;
|
||||
}
|
||||
|
||||
return existingValue;
|
||||
}
|
||||
}
|
10
VoidCat/Model/Exceptions/VoidFileNotFoundException.cs
Normal file
10
VoidCat/Model/Exceptions/VoidFileNotFoundException.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace VoidCat.Model.Exceptions;
|
||||
|
||||
public class VoidFileNotFoundException : Exception
|
||||
{
|
||||
public VoidFileNotFoundException(Guid id)
|
||||
{
|
||||
Id = id;
|
||||
}
|
||||
public Guid Id { get; }
|
||||
}
|
8
VoidCat/Model/Exceptions/VoidNotAllowedException.cs
Normal file
8
VoidCat/Model/Exceptions/VoidNotAllowedException.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace VoidCat.Model.Exceptions;
|
||||
|
||||
public class VoidNotAllowedException : Exception
|
||||
{
|
||||
public VoidNotAllowedException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
16
VoidCat/Model/Extensions.cs
Normal file
16
VoidCat/Model/Extensions.cs
Normal file
@ -0,0 +1,16 @@
|
||||
namespace VoidCat.Model;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static Guid FromBase58Guid(this string base58)
|
||||
{
|
||||
var enc = new NBitcoin.DataEncoders.Base58Encoder();
|
||||
return new Guid(enc.DecodeData(base58));
|
||||
}
|
||||
|
||||
public static string ToBase58(this Guid id)
|
||||
{
|
||||
var enc = new NBitcoin.DataEncoders.Base58Encoder();
|
||||
return enc.EncodeData(id.ToByteArray());
|
||||
}
|
||||
}
|
@ -1,15 +1,32 @@
|
||||
namespace VoidCat.Model
|
||||
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace VoidCat.Model
|
||||
{
|
||||
public class VoidFile
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
public string? Name { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
[JsonConverter(typeof(Base58GuidConverter))]
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public VoidFileMeta Metadata { get; set; }
|
||||
|
||||
public ulong Size { get; init; }
|
||||
|
||||
public DateTimeOffset Uploaded { get; init; }
|
||||
}
|
||||
|
||||
public class InternalVoidFile : VoidFile
|
||||
{
|
||||
[JsonConverter(typeof(Base58GuidConverter))]
|
||||
public Guid EditSecret { get; init; }
|
||||
}
|
||||
|
||||
public class VoidFileMeta
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? MimeType { get; init; }
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,6 @@
|
||||
{
|
||||
public class VoidSettings
|
||||
{
|
||||
public string FilePath { get; init; } = "./data";
|
||||
public string DataDirectory { get; init; } = "./data";
|
||||
}
|
||||
}
|
||||
|
@ -5,8 +5,8 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var services = builder.Services;
|
||||
services.AddRouting();
|
||||
services.AddControllers();
|
||||
services.AddScoped<IFileIngressFactory, LocalDiskFileIngressFactory>();
|
||||
services.AddControllers().AddNewtonsoftJson();
|
||||
services.AddScoped<IFileStorage, LocalDiskFileIngressFactory>();
|
||||
services.AddScoped<IStatsCollector, InMemoryStatsCollector>();
|
||||
|
||||
var configuration = builder.Configuration;
|
||||
|
@ -1,9 +0,0 @@
|
||||
using VoidCat.Model;
|
||||
|
||||
namespace VoidCat.Services
|
||||
{
|
||||
public interface IFileIngressFactory
|
||||
{
|
||||
Task<VoidFile> Ingress(Stream inStream);
|
||||
}
|
||||
}
|
15
VoidCat/Services/IFileStorage.cs
Normal file
15
VoidCat/Services/IFileStorage.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using VoidCat.Model;
|
||||
|
||||
namespace VoidCat.Services
|
||||
{
|
||||
public interface IFileStorage
|
||||
{
|
||||
Task<VoidFile?> Get(Guid id);
|
||||
|
||||
Task<InternalVoidFile> Ingress(Stream inStream, CancellationToken cts);
|
||||
|
||||
Task Egress(Guid id, Stream outStream, CancellationToken cts);
|
||||
|
||||
Task UpdateInfo(VoidFile patch, Guid editSecret);
|
||||
}
|
||||
}
|
@ -1,15 +1,36 @@
|
||||
namespace VoidCat.Services
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace VoidCat.Services
|
||||
{
|
||||
public class InMemoryStatsCollector : IStatsCollector
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, ulong> _ingress = new();
|
||||
private readonly ConcurrentDictionary<Guid, ulong> _egress = new();
|
||||
|
||||
public ValueTask TrackIngress(Guid id, ulong amount)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
if (_ingress.ContainsKey(id) && _ingress.TryGetValue(id, out var v))
|
||||
{
|
||||
_ingress.TryUpdate(id, v + amount, v);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ingress.TryAdd(id, amount);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask TrackEgress(Guid id, ulong amount)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
if (_egress.ContainsKey(id) && _egress.TryGetValue(id, out var v))
|
||||
{
|
||||
_egress.TryUpdate(id, v + amount, v);
|
||||
}
|
||||
else
|
||||
{
|
||||
_egress.TryAdd(id, amount);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
using System.Buffers;
|
||||
using VoidCat.Model;
|
||||
|
||||
namespace VoidCat.Services;
|
||||
|
||||
public class LocalDiskFileIngressFactory : IFileIngressFactory
|
||||
{
|
||||
private readonly VoidSettings _settings;
|
||||
private readonly IStatsCollector _stats;
|
||||
|
||||
public LocalDiskFileIngressFactory(VoidSettings settings, IStatsCollector stats)
|
||||
{
|
||||
_settings = settings;
|
||||
_stats = stats;
|
||||
}
|
||||
|
||||
public async Task<VoidFile> Ingress(Stream inStream)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var fPath = mapPath(id);
|
||||
using var fsTemp = new FileStream(fPath, FileMode.Create, FileAccess.ReadWrite);
|
||||
|
||||
var buffer = MemoryPool<byte>.Shared.Rent();
|
||||
var total = 0UL;
|
||||
var readLength = 0;
|
||||
while ((readLength = await inStream.ReadAsync(buffer.Memory)) > 0)
|
||||
{
|
||||
await fsTemp.WriteAsync(buffer.Memory[..readLength]);
|
||||
await _stats.TrackIngress(id, (ulong)readLength);
|
||||
total += (ulong)readLength;
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Id = id,
|
||||
Size = total
|
||||
};
|
||||
}
|
||||
|
||||
private string mapPath(Guid id) =>
|
||||
Path.Join(_settings.FilePath, id.ToString());
|
||||
}
|
103
VoidCat/Services/LocalDiskFileStorage.cs
Normal file
103
VoidCat/Services/LocalDiskFileStorage.cs
Normal file
@ -0,0 +1,103 @@
|
||||
using System.Buffers;
|
||||
using Newtonsoft.Json;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.Exceptions;
|
||||
|
||||
namespace VoidCat.Services;
|
||||
|
||||
public class LocalDiskFileIngressFactory : IFileStorage
|
||||
{
|
||||
private readonly VoidSettings _settings;
|
||||
private readonly IStatsCollector _stats;
|
||||
|
||||
public LocalDiskFileIngressFactory(VoidSettings settings, IStatsCollector stats)
|
||||
{
|
||||
_settings = settings;
|
||||
_stats = stats;
|
||||
|
||||
if (!Directory.Exists(_settings.DataDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(_settings.DataDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<VoidFile?> Get(Guid id)
|
||||
{
|
||||
var path = MapMeta(id);
|
||||
if (!File.Exists(path)) throw new VoidFileNotFoundException(id);
|
||||
|
||||
var json = await File.ReadAllTextAsync(path);
|
||||
return JsonConvert.DeserializeObject<VoidFile>(json);
|
||||
}
|
||||
|
||||
public async Task Egress(Guid id, Stream outStream, CancellationToken cts)
|
||||
{
|
||||
var path = MapPath(id);
|
||||
if (!File.Exists(path)) throw new VoidFileNotFoundException(id);
|
||||
|
||||
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||
using var buffer = MemoryPool<byte>.Shared.Rent();
|
||||
var readLength = 0;
|
||||
while ((readLength = await fs.ReadAsync(buffer.Memory, cts)) > 0)
|
||||
{
|
||||
await outStream.WriteAsync(buffer.Memory[..readLength], cts);
|
||||
await _stats.TrackEgress(id, (ulong)readLength);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<InternalVoidFile> Ingress(Stream inStream, CancellationToken cts)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var fPath = MapPath(id);
|
||||
await using var fsTemp = new FileStream(fPath, FileMode.Create, FileAccess.ReadWrite);
|
||||
|
||||
using var buffer = MemoryPool<byte>.Shared.Rent();
|
||||
var total = 0UL;
|
||||
var readLength = 0;
|
||||
while ((readLength = await inStream.ReadAsync(buffer.Memory, cts)) > 0)
|
||||
{
|
||||
await fsTemp.WriteAsync(buffer.Memory[..readLength], cts);
|
||||
await _stats.TrackIngress(id, (ulong)readLength);
|
||||
total += (ulong)readLength;
|
||||
}
|
||||
|
||||
var fm = new InternalVoidFile()
|
||||
{
|
||||
Id = id,
|
||||
Size = total,
|
||||
Uploaded = DateTimeOffset.UtcNow,
|
||||
EditSecret = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var mPath = MapMeta(id);
|
||||
var json = JsonConvert.SerializeObject(fm);
|
||||
await File.WriteAllTextAsync(mPath, json, cts);
|
||||
return fm;
|
||||
}
|
||||
|
||||
public async Task UpdateInfo(VoidFile patch, Guid editSecret)
|
||||
{
|
||||
var path = MapMeta(patch.Id);
|
||||
if (!File.Exists(path)) throw new VoidFileNotFoundException(patch.Id);
|
||||
|
||||
var oldJson = await File.ReadAllTextAsync(path);
|
||||
var oldObj = JsonConvert.DeserializeObject<InternalVoidFile>(oldJson);
|
||||
|
||||
if (oldObj?.EditSecret != editSecret)
|
||||
{
|
||||
throw new VoidNotAllowedException("Edit secret incorrect");
|
||||
}
|
||||
|
||||
// only patch metadata
|
||||
oldObj.Metadata = patch.Metadata;
|
||||
|
||||
var json = JsonConvert.SerializeObject(oldObj);
|
||||
await File.WriteAllTextAsync(path, json);
|
||||
}
|
||||
|
||||
private string MapPath(Guid id) =>
|
||||
Path.Join(_settings.DataDirectory, id.ToString());
|
||||
|
||||
private string MapMeta(Guid id) =>
|
||||
Path.ChangeExtension(MapPath(id), ".json");
|
||||
}
|
@ -11,7 +11,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.14.0" />
|
||||
<PackageReference Include="NBitcoin" Version="6.0.19" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Don't publish the SPA source files, but do show them in the project files list -->
|
||||
|
@ -7,6 +7,6 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Settings": {
|
||||
"VoidSettings": "./data"
|
||||
"DataDirectory": "./data"
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,10 @@
|
||||
import './App.css';
|
||||
import {FilePreview} from "./FilePreview";
|
||||
import {Uploader} from "./Uploader";
|
||||
|
||||
function App() {
|
||||
|
||||
function selectFiles(e) {
|
||||
|
||||
}
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="drop" onClick={selectFiles}>
|
||||
<h3>Drop files here!</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
let hasPath = window.location.pathname !== "/";
|
||||
return hasPath ? <FilePreview id={window.location.pathname.substr(1)}/> : <Uploader/>;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
32
VoidCat/spa/src/Const.js
Normal file
32
VoidCat/spa/src/Const.js
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @constant {number} - Size of 1 kiB
|
||||
*/
|
||||
export const kiB = Math.pow(1024, 1);
|
||||
/**
|
||||
* @constant {number} - Size of 1 MiB
|
||||
*/
|
||||
export const MiB = Math.pow(1024, 2);
|
||||
/**
|
||||
* @constant {number} - Size of 1 GiB
|
||||
*/
|
||||
export const GiB = Math.pow(1024, 3);
|
||||
/**
|
||||
* @constant {number} - Size of 1 TiB
|
||||
*/
|
||||
export const TiB = Math.pow(1024, 4);
|
||||
/**
|
||||
* @constant {number} - Size of 1 PiB
|
||||
*/
|
||||
export const PiB = Math.pow(1024, 5);
|
||||
/**
|
||||
* @constant {number} - Size of 1 EiB
|
||||
*/
|
||||
export const EiB = Math.pow(1024, 6);
|
||||
/**
|
||||
* @constant {number} - Size of 1 ZiB
|
||||
*/
|
||||
export const ZiB = Math.pow(1024, 7);
|
||||
/**
|
||||
* @constant {number} - Size of 1 YiB
|
||||
*/
|
||||
export const YiB = Math.pow(1024, 8);
|
23
VoidCat/spa/src/FilePreview.js
Normal file
23
VoidCat/spa/src/FilePreview.js
Normal file
@ -0,0 +1,23 @@
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export function FilePreview(props) {
|
||||
let [info, setInfo] = useState();
|
||||
|
||||
async function loadInfo() {
|
||||
let req = await fetch(`/upload/${props.id}`);
|
||||
if(req.ok) {
|
||||
let info = await req.json();
|
||||
setInfo(info);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{info ? <a href={`/d/${info.id}`}>{info.metadata?.name ?? info.id}</a> : "Not Found"}
|
||||
</div>
|
||||
);
|
||||
}
|
20
VoidCat/spa/src/FileUpload.css
Normal file
20
VoidCat/spa/src/FileUpload.css
Normal file
@ -0,0 +1,20 @@
|
||||
.upload {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border: 1px solid grey;
|
||||
border-radius: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.upload .info {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.upload .status {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.upload dt {
|
||||
font-size: 12px;
|
||||
color: grey;
|
||||
}
|
66
VoidCat/spa/src/FileUpload.js
Normal file
66
VoidCat/spa/src/FileUpload.js
Normal file
@ -0,0 +1,66 @@
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import "./FileUpload.css";
|
||||
import {FormatBytes} from "./Util";
|
||||
|
||||
export function FileUpload(props) {
|
||||
let [speed, setSpeed] = useState(0);
|
||||
let [progress, setProgress] = useState(0);
|
||||
let [result, setResult] = useState();
|
||||
|
||||
async function doUpload() {
|
||||
let req = await fetch("/upload", {
|
||||
method: "POST",
|
||||
body: props.file,
|
||||
headers: {
|
||||
"content-type": "application/octet-stream"
|
||||
}
|
||||
});
|
||||
|
||||
if(req.ok) {
|
||||
let rsp = await req.json();
|
||||
console.log(rsp);
|
||||
setResult(rsp);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
if(result) {
|
||||
return (
|
||||
<dl>
|
||||
<dt>Link:</dt>
|
||||
<dd><a target="_blank" href={`/${result.id}`}>{result.id}</a></dd>
|
||||
</dl>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<dl>
|
||||
<dt>Speed:</dt>
|
||||
<dd>{FormatBytes(speed)}/s</dd>
|
||||
<dt>Progress:</dt>
|
||||
<dd>{(progress * 100).toFixed(0)}%</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
console.log(props.file);
|
||||
doUpload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="upload">
|
||||
<div className="info">
|
||||
<dl>
|
||||
<dt>Name:</dt>
|
||||
<dd>{props.file.name}</dd>
|
||||
<dt>Size:</dt>
|
||||
<dd>{FormatBytes(props.file.size)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="status">
|
||||
{renderStatus()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
42
VoidCat/spa/src/Uploader.js
Normal file
42
VoidCat/spa/src/Uploader.js
Normal file
@ -0,0 +1,42 @@
|
||||
import {Fragment, useState} from "react";
|
||||
import {FileUpload} from "./FileUpload";
|
||||
|
||||
export function Uploader(props) {
|
||||
let [files, setFiles] = useState([]);
|
||||
|
||||
function selectFiles(e) {
|
||||
let i = document.createElement('input');
|
||||
i.setAttribute('type', 'file');
|
||||
i.setAttribute('multiple', '');
|
||||
i.addEventListener('change', function (evt) {
|
||||
setFiles(evt.target.files);
|
||||
});
|
||||
i.click();
|
||||
}
|
||||
|
||||
function renderUploads() {
|
||||
let fElm = [];
|
||||
for(let f of files) {
|
||||
fElm.push(<FileUpload file={f}/>);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
{fElm}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDrop() {
|
||||
return (
|
||||
<div className="drop" onClick={selectFiles}>
|
||||
<h3>Drop files here!</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{files.length === 0 ? renderDrop() : renderUploads()}
|
||||
</div>
|
||||
);
|
||||
}
|
27
VoidCat/spa/src/Util.js
Normal file
27
VoidCat/spa/src/Util.js
Normal file
@ -0,0 +1,27 @@
|
||||
import * as Const from "./Const";
|
||||
/**
|
||||
* Formats bytes into binary notation
|
||||
* @param {number} b - The value in bytes
|
||||
* @param {number} [f=2] - The number of decimal places to use
|
||||
* @returns {string} Bytes formatted in binary notation
|
||||
*/
|
||||
export function FormatBytes(b, f) {
|
||||
f = typeof f === 'number' ? 2 : f;
|
||||
if (b >= Const.YiB)
|
||||
return (b / Const.YiB).toFixed(f) + ' YiB';
|
||||
if (b >= Const.ZiB)
|
||||
return (b / Const.ZiB).toFixed(f) + ' ZiB';
|
||||
if (b >= Const.EiB)
|
||||
return (b / Const.EiB).toFixed(f) + ' EiB';
|
||||
if (b >= Const.PiB)
|
||||
return (b / Const.PiB).toFixed(f) + ' PiB';
|
||||
if (b >= Const.TiB)
|
||||
return (b / Const.TiB).toFixed(f) + ' TiB';
|
||||
if (b >= Const.GiB)
|
||||
return (b / Const.GiB).toFixed(f) + ' GiB';
|
||||
if (b >= Const.MiB)
|
||||
return (b / Const.MiB).toFixed(f) + ' MiB';
|
||||
if (b >= Const.kiB)
|
||||
return (b / Const.kiB).toFixed(f) + ' KiB';
|
||||
return b.toFixed(f) + ' B'
|
||||
}
|
@ -1,15 +1,19 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user