mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-15 11:31:08 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# @pezkuwi/app-files
|
||||
|
||||
## Description
|
||||
|
||||
***Bizinikiwi Files*** is a decentralized storage module which allows bizinikiwi-based chains(including `Pezkuwi/Dicle/Crust/Acala/Clover/Moonbeam/Astar/Phala/...`) users upload their files to [IPFS W3Auth Gateway](https://wiki.crust.network/docs/en/buildIPFSWeb3AuthGW) and decentralized pin their files on [Crust Network](https://crust.network) by using the standard [IPFS W3Auth Pinning Service](https://wiki.crust.network/docs/en/buildIPFSW3AuthPin). This module is a 100% IPFS compatible file storage module, users can pin **NFT files**, host **DApps** or store **on-chain data** in totally DECENTRALIZED way(`guaranteed by Crust protocol`).
|
||||
Also, the Pinning Service is compatible with several Platforms like `Ethereum`, `Polygon`, `Solana` and `Near`, and funded by [Decentralized Cloud Foundation](https://decloudf.com/). So currently, `Bizinikiwi Files` is FREE for all the bizinikiwi-based chains!
|
||||
|
||||
## Features
|
||||
|
||||
Basically, the function is simple, it consist with 2 major parts: `Upload File` and `Manage File Directory`.
|
||||
|
||||
### Upload File
|
||||
|
||||
Upload files with bizinikiwi-based chains account, you can also copy the cid to [Crust Apps](https://apps.crust.network/?rpc=wss%3A%2F%2Frpc.crust.network#/storage) to see how many IPFS replicas of your file.
|
||||
|
||||
### Manage files directory
|
||||
|
||||
Because the whole files module is decentralized, so your file directory is cached in browser, but you can export and import your file directory info.
|
||||
|
||||
## References
|
||||
|
||||
- [Crust Network](https://crust.network)
|
||||
- [IPFS Web3 Authentication Gateway](https://wiki.crust.network/docs/en/buildIPFSWeb3AuthGW)
|
||||
- [IPFS Web3 Authentication Pinning Service](https://wiki.crust.network/docs/en/buildIPFSW3AuthPin)
|
||||
- [IPFS Public Gateway](https://docs.ipfs.io/concepts/ipfs-gateway/)
|
||||
- [IPFS Pinning Service](https://docs.ipfs.io/how-to/work-with-pinning-services/#use-an-existing-pinning-service)
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"bugs": "https://github.com/pezkuwichain/pezkuwi-apps/issues",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"homepage": "https://github.com/pezkuwichain/pezkuwi-apps/tree/master/packages/page-files#readme",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@pezkuwi/app-files",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"directory": "packages/page-files",
|
||||
"type": "git",
|
||||
"url": "https://github.com/pezkuwichain/pezkuwi-apps.git"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.168.2-4-x",
|
||||
"dependencies": {
|
||||
"@pezkuwi/react-components": "^0.168.2-4-x",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-is": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-files authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatusBase } from '@pezkuwi/react-components/Status/types';
|
||||
import type { DirFile, FileInfo, SaveFile } from './types.js';
|
||||
|
||||
import FileSaver from 'file-saver';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { Badge, Button, CopyButton, Icon, styled, Table } from '@pezkuwi/react-components';
|
||||
import { useQueue } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useFiles } from './hooks.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import UploadModal from './UploadModal.js';
|
||||
|
||||
const MCopyButton = styled(CopyButton)`
|
||||
.copySpan {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemFile = styled.tr`
|
||||
height: 3.5rem;
|
||||
|
||||
.end {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const shortStr = (name: string, count = 6): string => {
|
||||
if (name.length > (count * 2)) {
|
||||
return `${name.substring(0, count)}...${name.substring(name.length - count)}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
function createUrl (f: SaveFile) {
|
||||
const endpoint = 'https://cf-ipfs.com';
|
||||
|
||||
return `${endpoint}/ipfs/${f.Hash}?filename=${f.Name}`;
|
||||
}
|
||||
|
||||
const createOnDown = (f: SaveFile) => () => {
|
||||
window.open(createUrl(f), '_blank');
|
||||
// FileSaver.saveAs(createUrl(f), f.Name);
|
||||
};
|
||||
|
||||
type FunInputFile = (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
|
||||
const Noop = (): void => undefined;
|
||||
|
||||
export interface Props {
|
||||
className?: string,
|
||||
}
|
||||
|
||||
function CrustFiles ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { queueAction } = useQueue();
|
||||
const [showUpMode, setShowUpMode] = useState(false);
|
||||
const wFiles = useFiles();
|
||||
const [file, setFile] = useState<FileInfo | undefined>(undefined);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const _clickUploadFile = useCallback((dir = false) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
inputRef.current.webkitdirectory = dir;
|
||||
// eslint-disable-next-line
|
||||
inputRef.current.multiple = dir;
|
||||
inputRef.current.click();
|
||||
}, [inputRef]);
|
||||
const onClickUpFile = useCallback(() => _clickUploadFile(false), [_clickUploadFile]);
|
||||
const onClickUpFolder = useCallback(() => _clickUploadFile(true), [_clickUploadFile]);
|
||||
const _onInputFile = useCallback<FunInputFile>((e) => {
|
||||
const files = e.target.files;
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length > 2000) {
|
||||
queueAction({
|
||||
action: 'Upload Folder',
|
||||
message: t('Please do not upload more than 2000 files'),
|
||||
status: 'error'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
queueAction({
|
||||
action: 'Upload Folder',
|
||||
message: t('Please select non-empty folder'),
|
||||
status: 'error'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
const isDirectory = e.target.webkitdirectory;
|
||||
|
||||
if (!isDirectory) {
|
||||
setFile({ file: files[0] });
|
||||
setShowUpMode(true);
|
||||
} else if (files.length >= 1) {
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
const dirFiles: DirFile[] = [];
|
||||
|
||||
for (let i = 0, count = files.length; i < count; i++) {
|
||||
// console.info('f:', files[index]);
|
||||
dirFiles.push(files[i]);
|
||||
}
|
||||
|
||||
console.info(dirFiles);
|
||||
|
||||
const [dir] = dirFiles[0].webkitRelativePath.split('/');
|
||||
|
||||
setFile({ dir, files: dirFiles });
|
||||
setShowUpMode(true);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}, [setFile, setShowUpMode, queueAction, t]);
|
||||
|
||||
const _onImportResult = useCallback<(m: string, s?: ActionStatusBase['status']) => void>(
|
||||
(message, status = 'queued') => {
|
||||
queueAction && queueAction({
|
||||
action: t('Import files'),
|
||||
message,
|
||||
status
|
||||
});
|
||||
},
|
||||
[queueAction, t]
|
||||
);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const _clickImport = useCallback(() => {
|
||||
if (!importInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
importInputRef.current.click();
|
||||
}, [importInputRef]);
|
||||
const _onInputImportFile = useCallback<FunInputFile>((e) => {
|
||||
try {
|
||||
_onImportResult(t('Importing'));
|
||||
const fileReader = new FileReader();
|
||||
const files = e.target.files;
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
fileReader.readAsText(files[0], 'UTF-8');
|
||||
|
||||
if (!(/(.json)$/i.test(e.target.value))) {
|
||||
return _onImportResult(t('file error'), 'error');
|
||||
}
|
||||
|
||||
fileReader.onload = (e) => {
|
||||
const _list = JSON.parse(e.target?.result as string) as SaveFile[];
|
||||
|
||||
if (!Array.isArray(_list)) {
|
||||
return _onImportResult(t('file content error'), 'error');
|
||||
}
|
||||
|
||||
const fitter: SaveFile[] = [];
|
||||
const mapImport: Record<string, boolean> = {};
|
||||
|
||||
for (const item of _list) {
|
||||
if (item.Hash && item.Name && item.UpEndpoint && item.PinEndpoint) {
|
||||
fitter.push(item);
|
||||
mapImport[item.Hash] = true;
|
||||
}
|
||||
}
|
||||
|
||||
const filterOld = wFiles.files.filter((item) => !mapImport[item.Hash]);
|
||||
|
||||
wFiles.setFiles([...fitter, ...filterOld]);
|
||||
_onImportResult(t('Import Success'), 'success');
|
||||
};
|
||||
} catch {
|
||||
_onImportResult(t('file content error'), 'error');
|
||||
}
|
||||
}, [wFiles, _onImportResult, t]);
|
||||
|
||||
const _onClose = useCallback(() => {
|
||||
setShowUpMode(false);
|
||||
}, []);
|
||||
|
||||
const _onSuccess = useCallback((res: SaveFile) => {
|
||||
setShowUpMode(false);
|
||||
const filterFiles = wFiles.files.filter((f) => f.Hash !== res.Hash);
|
||||
|
||||
wFiles.setFiles([res, ...filterFiles]);
|
||||
}, [wFiles]);
|
||||
|
||||
const _export = useCallback(() => {
|
||||
const blob = new Blob([JSON.stringify(wFiles.files)], { type: 'application/json; charset=utf-8' });
|
||||
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
FileSaver.saveAs(blob, 'files.json');
|
||||
}, [wFiles]);
|
||||
|
||||
return <StyledMain className={className}>
|
||||
<header></header>
|
||||
<input
|
||||
onChange={_onInputFile}
|
||||
ref={inputRef}
|
||||
style={{ display: 'none' }}
|
||||
type={'file'}
|
||||
/>
|
||||
<input
|
||||
onChange={_onInputImportFile}
|
||||
ref={importInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type={'file'}
|
||||
/>
|
||||
{file && showUpMode && (
|
||||
<UploadModal
|
||||
file={file}
|
||||
onClose={_onClose}
|
||||
onSuccess={_onSuccess}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', paddingBottom: '1.5rem' }}>
|
||||
<div className='uploadBtn'>
|
||||
<Button
|
||||
icon={'upload'}
|
||||
label={t('Upload')}
|
||||
onClick={Noop}
|
||||
/>
|
||||
<div className='uploadMenu'>
|
||||
<div
|
||||
className='menuItem'
|
||||
onClick={onClickUpFile}
|
||||
>{t('File')}</div>
|
||||
<div
|
||||
className='menuItem'
|
||||
onClick={onClickUpFolder}
|
||||
>{t('Folder')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button
|
||||
icon={'file-import'}
|
||||
label={t('Import')}
|
||||
onClick={_clickImport}
|
||||
/>
|
||||
<Button
|
||||
icon={'file-export'}
|
||||
label={t('Export')}
|
||||
onClick={_export}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
empty={t('No files')}
|
||||
emptySpinner={t('Loading')}
|
||||
header={[
|
||||
[t('files'), 'start', 2],
|
||||
[t('file cid'), 'expand', 2],
|
||||
[undefined, 'start'],
|
||||
[t('file size'), 'expand', 2],
|
||||
[t('status'), 'expand'],
|
||||
[t('action'), 'expand'],
|
||||
[]
|
||||
]}
|
||||
>
|
||||
{wFiles.files.map((f, index) =>
|
||||
<ItemFile key={`files_item-${index}`}>
|
||||
<td
|
||||
className=''
|
||||
colSpan={2}
|
||||
>
|
||||
{f.items && (
|
||||
<Icon
|
||||
className='highlight--color'
|
||||
icon='folder'
|
||||
/>
|
||||
)}
|
||||
{shortStr(f.Name)}</td>
|
||||
<td
|
||||
className='end'
|
||||
colSpan={2}
|
||||
>{f.Hash}</td>
|
||||
<td
|
||||
className=''
|
||||
colSpan={1}
|
||||
>
|
||||
<MCopyButton value={f.Hash}>
|
||||
<Badge
|
||||
color='highlight'
|
||||
hover={t('Copy file cid')}
|
||||
icon='copy'
|
||||
/>
|
||||
</MCopyButton>
|
||||
</td>
|
||||
<td
|
||||
className='end'
|
||||
colSpan={2}
|
||||
>{`${f.Size} bytes`}</td>
|
||||
<td
|
||||
className='end'
|
||||
colSpan={1}
|
||||
>
|
||||
<a
|
||||
href={'https://apps.crust.network/?rpc=wss%3A%2F%2Frpc.crust.network#/storage_files/status/' + f.Hash}
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>{t('View status in Crust')}</a>
|
||||
</td>
|
||||
<td
|
||||
className='end'
|
||||
colSpan={1}
|
||||
>
|
||||
<div className='actions'>
|
||||
{!f.items && (
|
||||
<Badge
|
||||
color='highlight'
|
||||
hover={t('Download')}
|
||||
icon='download'
|
||||
onClick={createOnDown(f)}
|
||||
/>
|
||||
)}
|
||||
<MCopyButton value={createUrl(f)}>
|
||||
<Badge
|
||||
color='highlight'
|
||||
hover={t('Copy link')}
|
||||
icon='copy'
|
||||
/>
|
||||
</MCopyButton>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
<td colSpan={1} />
|
||||
</ItemFile>
|
||||
)}
|
||||
</Table>
|
||||
<div>
|
||||
{t('Note: The file list is cached locally, switching browsers or devices will not keep displaying the original browser information.')}
|
||||
</div>
|
||||
</StyledMain>;
|
||||
}
|
||||
|
||||
const StyledMain = styled.main`
|
||||
h1 {
|
||||
text-transform: unset !important;
|
||||
}
|
||||
|
||||
.uploadBtn {
|
||||
position: relative;
|
||||
padding: 5px 0;
|
||||
|
||||
&:hover {
|
||||
.uploadMenu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uploadMenu {
|
||||
z-index: 200;
|
||||
display: none;
|
||||
background-color: var(--bg-table);
|
||||
position: absolute;
|
||||
top: 43px;
|
||||
left: 0;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
line-height: 40px;
|
||||
|
||||
.menuItem {
|
||||
cursor: pointer;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-page);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(CrustFiles);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-files authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
|
||||
export interface Props {
|
||||
className?: string,
|
||||
style?: CSSProperties,
|
||||
progress: number
|
||||
}
|
||||
|
||||
function Progress ({ className = '', progress, style }: Props) {
|
||||
return (
|
||||
<StyledDiv
|
||||
className={`${className} highlight--border`}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className='file-progress-bar highlight--bg'
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
width: 100%;
|
||||
background: unset;
|
||||
overflow: hidden;
|
||||
height: 1.4rem;
|
||||
border-radius: 0.7rem;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
|
||||
.file-progress-bar {
|
||||
transition: width 100ms ease-in-out;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo<Props>(Progress);
|
||||
@@ -0,0 +1,401 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-files authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { CancelTokenSource } from 'axios';
|
||||
import type { Signer } from '@pezkuwi/api/types';
|
||||
import type { AuthIpfsEndpoint, DirFile, FileInfo, SaveFile, UploadRes } from './types.js';
|
||||
|
||||
import axios from 'axios';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { web3FromSource } from '@pezkuwi/extension-dapp';
|
||||
import { Available, Button, Dropdown, InputAddress, Label, MarkError, Modal, Password, styled } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { isFunction, nextTick, stringToHex, stringToU8a, u8aToHex } from '@pezkuwi/util';
|
||||
|
||||
import Progress from './Progress.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
file: FileInfo,
|
||||
onClose?: () => void,
|
||||
onSuccess?: (res: SaveFile) => void,
|
||||
}
|
||||
|
||||
interface AccountState {
|
||||
isExternal: boolean;
|
||||
isHardware: boolean;
|
||||
isInjected: boolean;
|
||||
}
|
||||
|
||||
interface SignerState {
|
||||
isUsable: boolean;
|
||||
signer: Signer | null;
|
||||
}
|
||||
|
||||
const NOOP = (): void => undefined;
|
||||
|
||||
function ShowFile (p: { file: DirFile | File }) {
|
||||
const f = p.file as DirFile;
|
||||
|
||||
return (
|
||||
<div className='file'>
|
||||
<Label label={f.webkitRelativePath || p.file.name} />
|
||||
<span>{`${f.size} bytes`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createAuthIpfsEndpoints (t: (key: string, options?: { replace: Record<string, unknown> }) => string): AuthIpfsEndpoint[] {
|
||||
return [
|
||||
{
|
||||
location: t('Singapore'),
|
||||
text: t('DCF'),
|
||||
value: 'https://crustipfs.xyz'
|
||||
},
|
||||
{
|
||||
location: t('Seattle'),
|
||||
text: t('Crust Network'),
|
||||
value: 'https://gw.crustfiles.app'
|
||||
},
|
||||
{
|
||||
location: t('Berlin'),
|
||||
text: t('⚡️ Thunder Gateway'),
|
||||
value: 'https://gw.crustfiles.net'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function UploadModal ({ className, file, onClose = NOOP, onSuccess = NOOP }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = useMemo(
|
||||
() => createAuthIpfsEndpoints(t)
|
||||
.sort(() => Math.random() > 0.5 ? -1 : 1)
|
||||
.map((item) => ({ ...item, text: `${item.text ?? ''}(${item.location ?? ''})` })),
|
||||
[t]
|
||||
);
|
||||
const [currentEndpoint, setCurrentEndpoint] = useState(endpoints[0]);
|
||||
const pinEndpoints = useMemo(() => [
|
||||
{
|
||||
text: t('Crust Pinner'),
|
||||
value: 'https://pin.crustcode.com'
|
||||
}
|
||||
], [t]);
|
||||
const [currentPinEndpoint, setCurrentPinEndpoint] = useState(pinEndpoints[0]);
|
||||
const [currentPair, setCurrentPair] = useState(() => keyring.getPairs()[0] || null);
|
||||
const [account, setAccount] = useState('');
|
||||
const [{ isInjected }, setAccountState] = useState<AccountState>({
|
||||
isExternal: false,
|
||||
isHardware: false,
|
||||
isInjected: false
|
||||
});
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [{ isUsable, signer }, setSigner] = useState<SignerState>({ isUsable: true, signer: null });
|
||||
const [password, setPassword] = useState('');
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const fileSizeError = useMemo(() => {
|
||||
const MAX = 100 * 1024 * 1024;
|
||||
|
||||
if (file.file) {
|
||||
return file.file.size > MAX;
|
||||
} else if (file.files) {
|
||||
let sum = 0;
|
||||
|
||||
for (const f of file.files) {
|
||||
sum += f.size;
|
||||
}
|
||||
|
||||
return sum > MAX;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [file]);
|
||||
const [error, setError] = useState('');
|
||||
const errorText = fileSizeError ? t('Do not upload files larger than 100MB!') : error;
|
||||
const [upState, setUpState] = useState({ progress: 0, up: false });
|
||||
const [cancelUp, setCancelUp] = useState<CancelTokenSource | null>(null);
|
||||
|
||||
const onAccountChange = useCallback((nAccount: string | null) => {
|
||||
if (nAccount) {
|
||||
setAccount(nAccount);
|
||||
setCurrentPair(keyring.getPair(nAccount));
|
||||
}
|
||||
}, [setAccount, setCurrentPair]);
|
||||
|
||||
useEffect(() => {
|
||||
const meta = currentPair?.meta || {};
|
||||
const isExternal = meta.isExternal || false;
|
||||
const isHardware = meta.isHardware || false;
|
||||
const isInjected = meta.isInjected || false;
|
||||
const isUsable = !(isExternal || isHardware || isInjected);
|
||||
|
||||
setAccountState({ isExternal, isHardware, isInjected });
|
||||
setIsLocked(
|
||||
isInjected
|
||||
? false
|
||||
: (currentPair && currentPair.isLocked) || false
|
||||
);
|
||||
setSigner({ isUsable, signer: null });
|
||||
|
||||
// for injected, retrieve the signer
|
||||
if (meta.source && isInjected) {
|
||||
web3FromSource(meta.source)
|
||||
.catch(() => null)
|
||||
.then((injected) => setSigner({
|
||||
isUsable: isFunction(injected?.signer?.signRaw),
|
||||
signer: injected?.signer || null
|
||||
}))
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [currentPair]);
|
||||
|
||||
const unLock = useCallback(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
nextTick((): void => {
|
||||
try {
|
||||
currentPair.decodePkcs8(password);
|
||||
resolve(1);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [currentPair, password]);
|
||||
|
||||
const _onClose = useCallback(() => {
|
||||
if (cancelUp) {
|
||||
cancelUp.cancel();
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [cancelUp, onClose]);
|
||||
|
||||
const _onClickUp = useCallback(async () => {
|
||||
setError('');
|
||||
|
||||
if (!isUsable || !currentPair) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1: sign
|
||||
setBusy(true);
|
||||
|
||||
if (isLocked) {
|
||||
await unLock();
|
||||
}
|
||||
|
||||
let signature = '';
|
||||
|
||||
if (signer && isFunction(signer.signRaw)) {
|
||||
const res = await signer.signRaw({
|
||||
address: currentPair.address,
|
||||
data: stringToHex(currentPair.address),
|
||||
type: 'bytes'
|
||||
});
|
||||
|
||||
signature = res.signature;
|
||||
} else {
|
||||
signature = u8aToHex(currentPair.sign(stringToU8a(currentPair.address)));
|
||||
}
|
||||
|
||||
const perSignData = `${currentPair.address}:${signature}`;
|
||||
const base64Signature = Buffer.from(perSignData).toString('base64');
|
||||
const AuthBasic = `Basic ${base64Signature}`;
|
||||
const AuthBearer = `Bearer ${base64Signature}`;
|
||||
const cancel = axios.CancelToken.source();
|
||||
|
||||
setCancelUp(cancel);
|
||||
setUpState({ progress: 0, up: true });
|
||||
|
||||
const form = new FormData();
|
||||
|
||||
if (file.file) {
|
||||
form.append('file', file.file, file.file.name);
|
||||
} else if (file.files) {
|
||||
for (const f of file.files) {
|
||||
form.append('file', f, f.webkitRelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
const UpEndpoint = currentEndpoint.value;
|
||||
const upResult = await axios.request<UploadRes | string>({
|
||||
cancelToken: cancel.token,
|
||||
data: form,
|
||||
headers: { Authorization: AuthBasic },
|
||||
maxContentLength: 100 * 1024 * 1024,
|
||||
method: 'POST',
|
||||
onUploadProgress: ({ loaded, total }) => {
|
||||
const percent = loaded / (total || loaded || 1);
|
||||
|
||||
setUpState({ progress: Math.round(percent * 99), up: true });
|
||||
},
|
||||
params: { pin: true },
|
||||
url: `${UpEndpoint}/api/v0/add`
|
||||
});
|
||||
|
||||
let upRes: UploadRes;
|
||||
|
||||
if (typeof upResult.data === 'string') {
|
||||
const jsonStr = upResult.data.replace(/}\n{/g, '},{');
|
||||
const items = JSON.parse(`[${jsonStr}]`) as UploadRes[];
|
||||
const folder = items.length - 1;
|
||||
|
||||
upRes = items[folder];
|
||||
delete items[folder];
|
||||
upRes.items = items;
|
||||
} else {
|
||||
upRes = upResult.data;
|
||||
}
|
||||
|
||||
console.info('upResult:', upResult);
|
||||
setCancelUp(null);
|
||||
setUpState({ progress: 100, up: false });
|
||||
// remote pin order
|
||||
const PinEndpoint = currentPinEndpoint.value;
|
||||
|
||||
await axios.request({
|
||||
data: {
|
||||
cid: upRes.Hash,
|
||||
name: upRes.Name
|
||||
},
|
||||
headers: { Authorization: AuthBearer },
|
||||
method: 'POST',
|
||||
url: `${PinEndpoint}/psa/pins`
|
||||
});
|
||||
onSuccess({
|
||||
...upRes,
|
||||
PinEndpoint,
|
||||
UpEndpoint
|
||||
});
|
||||
} catch (e) {
|
||||
setUpState({ progress: 0, up: false });
|
||||
setBusy(false);
|
||||
console.error(e);
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [file, unLock, signer, isLocked, isUsable, currentPair, currentPinEndpoint, currentEndpoint, onSuccess]);
|
||||
|
||||
const _onChangeGateway = useCallback((value: string) => {
|
||||
const find = endpoints.find((item) => item.value === value);
|
||||
|
||||
if (find) {
|
||||
setCurrentEndpoint(find);
|
||||
}
|
||||
}, [endpoints, setCurrentEndpoint]);
|
||||
|
||||
const _onChangePinner = useCallback((value: string) => {
|
||||
const find = pinEndpoints.find((item) => item.value === value);
|
||||
|
||||
if (find) {
|
||||
setCurrentPinEndpoint(find);
|
||||
}
|
||||
}, [pinEndpoints, setCurrentPinEndpoint]);
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Upload File')}
|
||||
onClose={_onClose}
|
||||
open={true}
|
||||
size={'medium'}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns>
|
||||
<div className='files'>
|
||||
{file.file && <ShowFile file={file.file} />}
|
||||
{file.files?.map((f, i) =>
|
||||
<ShowFile
|
||||
file={f}
|
||||
key={`file_item:${i}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns>
|
||||
<Dropdown
|
||||
isDisabled={isBusy}
|
||||
label={t('Select a Web3 IPFS Gateway')}
|
||||
onChange={_onChangeGateway}
|
||||
options={endpoints}
|
||||
value={currentEndpoint.value}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns>
|
||||
<Dropdown
|
||||
isDisabled={true}
|
||||
label={t('Select a Web3 IPFS Pinner')}
|
||||
onChange={_onChangePinner}
|
||||
options={pinEndpoints}
|
||||
value={currentPinEndpoint.value}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns>
|
||||
<InputAddress
|
||||
defaultValue={account}
|
||||
isDisabled={isBusy}
|
||||
label={t('Please choose account')}
|
||||
labelExtra={
|
||||
<Available
|
||||
label={t('transferable')}
|
||||
params={account}
|
||||
/>
|
||||
}
|
||||
onChange={onAccountChange}
|
||||
type='account'
|
||||
/>
|
||||
{
|
||||
!upState.up && isLocked && !isInjected &&
|
||||
<Password
|
||||
isError={false}
|
||||
label={t('password')}
|
||||
onChange={setPassword}
|
||||
value={password}
|
||||
/>
|
||||
}
|
||||
<Progress
|
||||
className='progress'
|
||||
progress={upState.progress}
|
||||
/>
|
||||
{errorText && (
|
||||
<MarkError content={errorText} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon={'arrow-circle-up'}
|
||||
isBusy={isBusy}
|
||||
isDisabled={fileSizeError}
|
||||
label={t('Sign and Upload')}
|
||||
onClick={_onClickUp}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.files {
|
||||
max-height: 300;
|
||||
overflow: auto;
|
||||
padding-left: 2rem;
|
||||
width: 100%;
|
||||
|
||||
.file {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 5px 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin-left: 2rem;
|
||||
margin-top: 2rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(UploadModal);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-files authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SaveFile } from './types.js';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import store from 'store';
|
||||
|
||||
export interface Files {
|
||||
files: SaveFile[],
|
||||
}
|
||||
|
||||
export interface WrapFiles extends Files {
|
||||
isLoad: boolean,
|
||||
setFiles: (files: SaveFile[]) => void
|
||||
}
|
||||
|
||||
const defFilesObj = { files: [], isLoad: true };
|
||||
|
||||
export function useFiles (): WrapFiles {
|
||||
const [filesObj, setFilesObj] = useState<Files>(defFilesObj);
|
||||
const [isLoad, setIsLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const f = store.get('files', defFilesObj) as Files;
|
||||
|
||||
if (f !== defFilesObj) {
|
||||
setFilesObj(f);
|
||||
}
|
||||
|
||||
setIsLoad(false);
|
||||
} catch (e) {
|
||||
setIsLoad(false);
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
const setFiles = useCallback((nFiles: SaveFile[]) => {
|
||||
const nFilesObj = { ...filesObj, files: nFiles };
|
||||
|
||||
setFilesObj(nFilesObj);
|
||||
store.set('files', nFilesObj);
|
||||
}, [filesObj]);
|
||||
|
||||
return useMemo(() => ({ ...filesObj, isLoad, setFiles }), [filesObj, setFiles, isLoad]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-files authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
|
||||
import CrustFiles from './CrustFiles.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
basePath: string;
|
||||
className?: string;
|
||||
newEvents?: KeyedEvent[];
|
||||
}
|
||||
|
||||
function FilesApp ({ basePath, className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const itemsRef = useRef([
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'crust',
|
||||
text: t('Your Files')
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className={className}>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={itemsRef.current}
|
||||
/>
|
||||
<CrustFiles />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(FilesApp);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-files authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-files');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-files authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export interface UploadRes {
|
||||
Hash: string,
|
||||
Size: string,
|
||||
Name: string,
|
||||
items?: UploadRes[],
|
||||
}
|
||||
|
||||
export interface DirFile extends File {
|
||||
webkitRelativePath: string,
|
||||
}
|
||||
|
||||
export interface SaveFile extends UploadRes {
|
||||
UpEndpoint: string,
|
||||
PinEndpoint: string,
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
file?: File,
|
||||
files?: DirFile[],
|
||||
dir?: string,
|
||||
}
|
||||
|
||||
export interface AuthIpfsEndpoint {
|
||||
text?: string;
|
||||
value: string;
|
||||
location?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "..",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../react-components/tsconfig.build.json" },
|
||||
{ "path": "../react-hooks/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user