forked from Qortal/Q-Manager
Major update - v0.2.0 - see CHANGELOG for changes.
This commit is contained in:
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.2.0] - 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- Filesystem persistence now supports IndexedDB with localStorage backup/fallback behavior.
|
||||
- Versioned/timestamped storage records for safer persistence reconciliation.
|
||||
- QDN filesystem structure sync actions.
|
||||
- Option to publish filesystem structure to QDN.
|
||||
- Option to import filesystem structure from QDN.
|
||||
- Option to discover previously published Q-Manager resources and import them into the UI.
|
||||
- Automatic import destination folder: `Recovered Imports`.
|
||||
- Multi-select file workflow with per-item checkboxes in the grid.
|
||||
- Bulk selected-file action mode in bottom controls: `Move`, `Remove`, `Delete from QDN`.
|
||||
- Bulk move modal for selected files with target folder selection.
|
||||
- QDN tombstone delete flow by republishing each selected file identifier with `data64` for `"d"`.
|
||||
- File preview support from the main grid.
|
||||
- Right-click context menu action: `preview`.
|
||||
- Double-click file behavior to open preview.
|
||||
- Optional `Show thumbnails` checkbox above the main action controls.
|
||||
- Image thumbnails in file tiles when thumbnail mode is enabled.
|
||||
- File `displayName` support for UI labels (separate from published `name` / `identifier`).
|
||||
- Right-click context menu action: `More info` modal with full known file metadata dump.
|
||||
- Optional live metadata fetch from QDN resource properties (`GET_QDN_RESOURCE_PROPERTIES`) from the `More info` modal.
|
||||
- Automatic metadata hydration back into file nodes from fetched properties (size, mime, display filename when appropriate).
|
||||
- Selected-files footer now shows aggregated file size with unknown-count fallback.
|
||||
- Per-file size display in selected file details dialog.
|
||||
- File pinning support (`pin file` / `unpin file`) persisted in filesystem data.
|
||||
- Visual pin badge on pinned file tiles.
|
||||
- Extension-based preview inference and text preview mode (`.txt`, `.md`, `.json`, etc.).
|
||||
|
||||
### Changed
|
||||
|
||||
- IndexedDB is treated as the primary storage source, with localStorage retained as backup.
|
||||
- Storage load flow now heals missing/failed IndexedDB state from localStorage fallback data when needed.
|
||||
- Storage save flow now writes through a combined helper to reduce drift between stores.
|
||||
- Folder/file tile visuals were refreshed for stronger readability and hierarchy.
|
||||
- File rename behavior now updates `displayName` for files (folder rename behavior remains structural).
|
||||
- Selection is cleared when switching top-level mode tabs (`public` / `private` / `groups`) or selected group.
|
||||
- Missing/discovered file imports now attempt property hydration and use resolved filename for display labels.
|
||||
- `remove directory` context action now uses a delete icon (pin icon reserved for pinning behavior).
|
||||
|
||||
### Fixed
|
||||
|
||||
- `utils.ts` TypeScript typing issues and Promise/file handling edge cases.
|
||||
- Publish service default selection precedence bug in single publish flow.
|
||||
- Publish service dropdown menu rendering/opacity issues in modal context.
|
||||
- Better error fallback behavior for preview/thumbnails when media cannot be loaded.
|
||||
- Custom button component now honors the `disabled` prop.
|
||||
- Discovery/import now ignores tombstoned QDN resources by filtering very small (delete marker) resource sizes.
|
||||
- Preview fallback now handles text-like resources better when MIME metadata is missing by deriving from filename extension.
|
||||
|
||||
### Notes
|
||||
|
||||
- QDN publish/import works with the in-memory filesystem state, not direct storage backend snapshots.
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "QManager",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "QManager",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.2.0",
|
||||
"@dnd-kit/sortable": "^9.0.0",
|
||||
@@ -71,6 +71,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
|
||||
"integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.0",
|
||||
@@ -257,6 +258,7 @@
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
|
||||
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
@@ -321,6 +323,7 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.2.0.tgz",
|
||||
"integrity": "sha512-KVK/CJmaYGTxTPU6P0+Oy4itgffTUa80B8317sXzfOr1qUzSL29jE7Th11llXiu2haB7B9Glpzo2CDElin+geQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -448,6 +451,7 @@
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.5.tgz",
|
||||
"integrity": "sha512-gnOQ+nGLPvDXgIx119JqGalys64lhMdnNQA9TMxhDA4K0Hq5+++OE20Zs5GxiCV9r814xQ2K5WmtofSpHVW6BQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -938,6 +942,7 @@
|
||||
"version": "5.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.7.tgz",
|
||||
"integrity": "sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@mui/core-downloads-tracker": "^5.16.7",
|
||||
@@ -1158,6 +1163,7 @@
|
||||
"version": "18.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -1296,6 +1302,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001669",
|
||||
"electron-to-chromium": "^1.5.41",
|
||||
@@ -1422,7 +1429,8 @@
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
@@ -1684,7 +1692,6 @@
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
@@ -1692,8 +1699,7 @@
|
||||
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"peer": true
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
@@ -2089,6 +2095,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2100,6 +2107,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -2435,6 +2443,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz",
|
||||
"integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.18.10",
|
||||
"postcss": "^8.4.27",
|
||||
@@ -2630,6 +2639,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
|
||||
"integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.0",
|
||||
@@ -2763,6 +2773,7 @@
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
|
||||
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
}
|
||||
@@ -2812,6 +2823,7 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.2.0.tgz",
|
||||
"integrity": "sha512-KVK/CJmaYGTxTPU6P0+Oy4itgffTUa80B8317sXzfOr1qUzSL29jE7Th11llXiu2haB7B9Glpzo2CDElin+geQ==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -2920,6 +2932,7 @@
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.5.tgz",
|
||||
"integrity": "sha512-gnOQ+nGLPvDXgIx119JqGalys64lhMdnNQA9TMxhDA4K0Hq5+++OE20Zs5GxiCV9r814xQ2K5WmtofSpHVW6BQ==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -3168,6 +3181,7 @@
|
||||
"version": "5.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.7.tgz",
|
||||
"integrity": "sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@mui/core-downloads-tracker": "^5.16.7",
|
||||
@@ -3273,6 +3287,7 @@
|
||||
"version": "18.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -3362,6 +3377,7 @@
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
|
||||
"integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"caniuse-lite": "^1.0.30001669",
|
||||
"electron-to-chromium": "^1.5.41",
|
||||
@@ -3450,7 +3466,8 @@
|
||||
"csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"peer": true
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.3.7",
|
||||
@@ -3638,7 +3655,6 @@
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"react-is": "^16.7.0"
|
||||
},
|
||||
@@ -3646,8 +3662,7 @@
|
||||
"react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"peer": true
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3920,6 +3935,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
@@ -3928,6 +3944,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -4148,6 +4165,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz",
|
||||
"integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"esbuild": "^0.18.10",
|
||||
"fsevents": "~2.3.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "QManager",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
41
src/App.jsx
41
src/App.jsx
@@ -86,17 +86,6 @@ function App() {
|
||||
<div className="container">
|
||||
|
||||
{isLoading && (
|
||||
<Box sx={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'flex'
|
||||
}}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{!isLoading && !myAddress?.name?.name && (
|
||||
<Box sx={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
@@ -104,21 +93,29 @@ function App() {
|
||||
alignItems: 'center',
|
||||
display: 'flex'
|
||||
}}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{!isLoading && !myAddress?.name?.name && (
|
||||
<Box sx={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'flex'
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
To use Q-Manager you need a registered Qortal Name
|
||||
</Typography>
|
||||
}}>
|
||||
To use Q-Manager you need a registered Qortal Name
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
)}
|
||||
{!isLoading && myAddress?.name?.name && (
|
||||
<Manager myAddress={myAddress} groups={groups} />
|
||||
|
||||
)}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
/>
|
||||
)}
|
||||
{!isLoading && myAddress?.name?.name && (
|
||||
<Manager myAddress={myAddress} groups={groups} />
|
||||
)}
|
||||
<Toaster position="top-center"/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Menu, MenuItem, Modal, Typography, styled } from '@mui/material';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove';
|
||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
const CustomStyledMenu = styled(Menu)(({ theme }) => ({
|
||||
'& .MuiPaper-root': {
|
||||
backgroundColor: '#f9f9f9',
|
||||
@@ -22,13 +24,76 @@ const CustomStyledMenu = styled(Menu)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const getValueByKeys = (source, keys = []) => {
|
||||
if (!source || typeof source !== 'object') return undefined;
|
||||
for (const key of keys) {
|
||||
if (source[key] !== undefined && source[key] !== null) {
|
||||
return source[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeResourceProperties = (properties) => {
|
||||
if (!properties || typeof properties !== 'object') return {};
|
||||
|
||||
const filename = getValueByKeys(properties, ['filename', 'fileName']);
|
||||
const mimeType = getValueByKeys(properties, [
|
||||
'mimeType',
|
||||
'mime',
|
||||
'contentType',
|
||||
'mediaType',
|
||||
]);
|
||||
const rawSize = getValueByKeys(properties, [
|
||||
'sizeInBytes',
|
||||
'size',
|
||||
'dataSize',
|
||||
'createdSize',
|
||||
'totalSize',
|
||||
]);
|
||||
const qortalName = getValueByKeys(properties, ['name', 'qortalName', 'ownerName']);
|
||||
const title = getValueByKeys(properties, ['title']);
|
||||
const parsedSize = Number(rawSize);
|
||||
const sizeInBytes =
|
||||
Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : undefined;
|
||||
|
||||
return {
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { mimeType } : {}),
|
||||
...(sizeInBytes !== undefined ? { sizeInBytes } : {}),
|
||||
...(qortalName ? { qortalName } : {}),
|
||||
...(title ? { title } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildResourcePropertyPayloads = (item) => {
|
||||
const basePayload = {
|
||||
action: 'GET_QDN_RESOURCE_PROPERTIES',
|
||||
service: item?.service,
|
||||
identifier: item?.identifier,
|
||||
};
|
||||
const ownerName = item?.qortalName || item?.name;
|
||||
if (!ownerName) {
|
||||
return [basePayload];
|
||||
}
|
||||
return [
|
||||
{ ...basePayload, name: ownerName },
|
||||
{ ...basePayload, qortalName: ownerName },
|
||||
basePayload,
|
||||
];
|
||||
};
|
||||
|
||||
export const ContextMenuPinnedFiles = ({ children, removeFile, removeDirectory, type, rename, fileSystem,
|
||||
moveNode, currentPath, item }) => {
|
||||
moveNode, currentPath, item, onPreview, onHydrateMetadata, pinned, onTogglePin }) => {
|
||||
const [menuPosition, setMenuPosition] = useState(null);
|
||||
const longPressTimeout = useRef(null);
|
||||
const maxHoldTimeout = useRef(null);
|
||||
const preventClick = useRef(false);
|
||||
const [showMoveModal, setShowMoveModal] = useState(false);
|
||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||
const [resourceProperties, setResourceProperties] = useState(null);
|
||||
const [resourcePropertiesError, setResourcePropertiesError] = useState('');
|
||||
const [isFetchingResourceProperties, setIsFetchingResourceProperties] = useState(false);
|
||||
const [targetPath, setTargetPath] = useState([]);
|
||||
const startTouchPosition = useRef({ x: 0, y: 0 }); // Track initial touch position
|
||||
const handleContextMenu = (event) => {
|
||||
@@ -88,9 +153,8 @@ moveNode, currentPath, item }) => {
|
||||
};
|
||||
|
||||
const handleClose = (e) => {
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e?.preventDefault) e.preventDefault();
|
||||
if (e?.stopPropagation) e.stopPropagation();
|
||||
setMenuPosition(null);
|
||||
};
|
||||
|
||||
@@ -174,6 +238,111 @@ moveNode, currentPath, item }) => {
|
||||
const closeMoveModal = () => {
|
||||
setShowMoveModal(false);
|
||||
};
|
||||
const closeInfoModal = () => {
|
||||
setShowInfoModal(false);
|
||||
};
|
||||
|
||||
const hasKnownFileMetadata = useMemo(() => {
|
||||
if (type !== 'file') return true;
|
||||
const existingSize = getValueByKeys(item, [
|
||||
'sizeInBytes',
|
||||
'size',
|
||||
'fileSize',
|
||||
'dataSize',
|
||||
'createdSize',
|
||||
'totalSize',
|
||||
]);
|
||||
return Boolean(item?.mimeType) && existingSize !== undefined;
|
||||
}, [
|
||||
item?.mimeType,
|
||||
item?.sizeInBytes,
|
||||
item?.size,
|
||||
item?.fileSize,
|
||||
item?.dataSize,
|
||||
item?.createdSize,
|
||||
item?.totalSize,
|
||||
type,
|
||||
]);
|
||||
|
||||
const mergedItemInfo = useMemo(() => {
|
||||
if (!resourceProperties) return item;
|
||||
return {
|
||||
...item,
|
||||
fetchedResourceProperties: resourceProperties,
|
||||
};
|
||||
}, [item, resourceProperties]);
|
||||
|
||||
const fetchResourceProperties = async () => {
|
||||
if (type !== 'file' || !item?.service || !item?.identifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingResourceProperties(true);
|
||||
setResourcePropertiesError('');
|
||||
|
||||
const payloadAttempts = buildResourcePropertyPayloads(item);
|
||||
let lastError = null;
|
||||
|
||||
for (const payload of payloadAttempts) {
|
||||
try {
|
||||
const response = await qortalRequest(payload);
|
||||
if (response === undefined || response === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setResourceProperties(response);
|
||||
const normalized = normalizeResourceProperties(response);
|
||||
const metadataToHydrate = { ...normalized };
|
||||
if (
|
||||
normalized?.filename &&
|
||||
(!item?.displayName ||
|
||||
item?.displayName === item?.name ||
|
||||
item?.displayName === item?.identifier)
|
||||
) {
|
||||
metadataToHydrate.displayName = normalized.filename;
|
||||
}
|
||||
if (
|
||||
onHydrateMetadata &&
|
||||
typeof onHydrateMetadata === 'function' &&
|
||||
Object.keys(metadataToHydrate).length > 0
|
||||
) {
|
||||
onHydrateMetadata(metadataToHydrate);
|
||||
}
|
||||
setIsFetchingResourceProperties(false);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
setResourcePropertiesError(
|
||||
lastError?.error ||
|
||||
lastError?.message ||
|
||||
'Unable to fetch live QDN properties'
|
||||
);
|
||||
setIsFetchingResourceProperties(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setResourceProperties(null);
|
||||
setResourcePropertiesError('');
|
||||
setIsFetchingResourceProperties(false);
|
||||
}, [item?.identifier, item?.service, item?.qortalName, item?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showInfoModal) return;
|
||||
if (type !== 'file') return;
|
||||
if (isFetchingResourceProperties) return;
|
||||
if (resourceProperties) return;
|
||||
if (hasKnownFileMetadata) return;
|
||||
fetchResourceProperties();
|
||||
}, [
|
||||
showInfoModal,
|
||||
type,
|
||||
isFetchingResourceProperties,
|
||||
resourceProperties,
|
||||
hasKnownFileMetadata,
|
||||
]);
|
||||
|
||||
const handleMove = () => {
|
||||
if (targetPath.length > 0) {
|
||||
@@ -205,6 +374,32 @@ moveNode, currentPath, item }) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{type === 'file' && !!onPreview && (
|
||||
<MenuItem onClick={(e) => {
|
||||
handleClose(e);
|
||||
onPreview()
|
||||
}}>
|
||||
<ListItemIcon sx={{ minWidth: '32px' }}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit" sx={{ fontSize: '14px' }}>
|
||||
preview
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
{type === 'file' && (
|
||||
<MenuItem onClick={(e) => {
|
||||
handleClose(e);
|
||||
onTogglePin?.();
|
||||
}}>
|
||||
<ListItemIcon sx={{ minWidth: '32px' }}>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit" sx={{ fontSize: '14px' }}>
|
||||
{pinned ? 'unpin file' : 'pin file'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
{type === 'file' && (
|
||||
<MenuItem onClick={(e) => {
|
||||
handleClose(e);
|
||||
@@ -224,7 +419,7 @@ moveNode, currentPath, item }) => {
|
||||
removeDirectory()
|
||||
}}>
|
||||
<ListItemIcon sx={{ minWidth: '32px' }}>
|
||||
<PushPinIcon fontSize="small" />
|
||||
<DeleteIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit" sx={{ fontSize: '14px' }}>
|
||||
remove directory
|
||||
@@ -254,6 +449,19 @@ moveNode, currentPath, item }) => {
|
||||
<Typography variant="inherit" sx={{ fontSize: "14px" }}>
|
||||
Move
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
handleClose(e);
|
||||
setShowInfoModal(true);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: "32px" }}>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit" sx={{ fontSize: "14px" }}>
|
||||
More info
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
</CustomStyledMenu>
|
||||
|
||||
@@ -295,6 +503,64 @@ moveNode, currentPath, item }) => {
|
||||
<button onClick={closeMoveModal}>Cancel</button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
<Modal open={showInfoModal} onClose={closeInfoModal}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 520,
|
||||
maxWidth: "95%",
|
||||
margin: "auto",
|
||||
marginTop: "8%",
|
||||
backgroundColor: "#27282c",
|
||||
border: "1px solid #3a3f50",
|
||||
borderRadius: "10px",
|
||||
boxShadow: 24,
|
||||
p: 3,
|
||||
overflow: "auto",
|
||||
maxHeight: "80vh",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: "18px", mb: 1 }}>Item details</Typography>
|
||||
<Typography sx={{ fontSize: "13px", opacity: 0.75, mb: 2 }}>
|
||||
Showing all known metadata for this item.
|
||||
</Typography>
|
||||
{type === 'file' && (
|
||||
<Box sx={{ display: 'flex', gap: '10px', alignItems: 'center', mb: 2 }}>
|
||||
<button
|
||||
onClick={fetchResourceProperties}
|
||||
disabled={
|
||||
isFetchingResourceProperties || !item?.service || !item?.identifier
|
||||
}
|
||||
>
|
||||
{isFetchingResourceProperties
|
||||
? 'Fetching properties...'
|
||||
: 'Fetch QDN properties'}
|
||||
</button>
|
||||
{resourcePropertiesError && (
|
||||
<Typography sx={{ fontSize: "12px", color: "#ff8f8f" }}>
|
||||
{resourcePropertiesError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
backgroundColor: "rgba(0,0,0,0.2)",
|
||||
borderRadius: "8px",
|
||||
p: 2,
|
||||
fontSize: "12px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color: "#d7e0ef",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(mergedItemInfo, null, 2)}
|
||||
</Box>
|
||||
<Box mt={2}>
|
||||
<button onClick={closeInfoModal}>Close</button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
44
src/File.tsx
44
src/File.tsx
@@ -27,6 +27,39 @@ import { Spacer } from "./components/Spacer";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { openToast } from "./components/openToast";
|
||||
|
||||
const getDisplayName = (file) => file?.displayName || file?.name || "";
|
||||
|
||||
const getFileSizeBytes = (file) => {
|
||||
const candidates = [
|
||||
file?.sizeInBytes,
|
||||
file?.size,
|
||||
file?.fileSize,
|
||||
file?.dataSize,
|
||||
file?.createdSize,
|
||||
file?.totalSize,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = Number(candidate);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatBytes = (value) => {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "Unknown";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = -1;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
if (unitIndex < 0) return `${bytes} B`;
|
||||
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const SelectedFile = ({
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
@@ -37,7 +70,8 @@ export const SelectedFile = ({
|
||||
}) => {
|
||||
const [selectedType, setSelectedType] = useState(0);
|
||||
const [isExpandMore, setIsExpandMore] = useState(false);
|
||||
const [customFileName, setCustomFileName] = useState(selectedFile?.name)
|
||||
const [customFileName, setCustomFileName] = useState(getDisplayName(selectedFile))
|
||||
const fileSizeBytes = getFileSizeBytes(selectedFile);
|
||||
useEffect(() => {
|
||||
if (selectedFile?.mimeType?.toLowerCase()?.includes("image")) {
|
||||
setSelectedType("IMAGE");
|
||||
@@ -45,6 +79,9 @@ export const SelectedFile = ({
|
||||
setSelectedType("ATTACHMENT");
|
||||
}
|
||||
}, [selectedFile?.mimeType]);
|
||||
useEffect(() => {
|
||||
setCustomFileName(getDisplayName(selectedFile));
|
||||
}, [selectedFile?.identifier, selectedFile?.service]);
|
||||
const createEmbedLink = async () => {
|
||||
|
||||
const promise = (async ()=> {
|
||||
@@ -127,7 +164,7 @@ export const SelectedFile = ({
|
||||
>
|
||||
<Toolbar>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
{selectedFile?.name}
|
||||
{getDisplayName(selectedFile)}
|
||||
</Typography>
|
||||
<IconButton
|
||||
edge="start"
|
||||
@@ -223,6 +260,9 @@ export const SelectedFile = ({
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: "13px", opacity: 0.82 }}>
|
||||
Size: {fileSizeBytes === null ? "Unknown" : formatBytes(fileSizeBytes)}
|
||||
</Typography>
|
||||
<Spacer height="10px" />
|
||||
{mode === 'private' && (
|
||||
<Box
|
||||
|
||||
1937
src/Manager.tsx
1937
src/Manager.tsx
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,10 @@ export const Transition = React.forwardRef(function Transition(props, ref) {
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export const ShowAction = ({ selectedAction, handleClose, myName, addNodeByPath, mode , groups, selectedGroup}) => {
|
||||
export const ShowAction = ({ selectedAction, handleClose, myName, addNodeByPath, mode , groups, selectedGroup, }) => {
|
||||
const ActionComponent = useMemo(() => {
|
||||
switch (selectedAction?.action) {
|
||||
|
||||
|
||||
case "PUBLISH_QDN_RESOURCE":
|
||||
return PUBLISH_QDN_RESOURCE;
|
||||
case "PUBLISH_MULTIPLE_QDN_RESOURCES":
|
||||
@@ -70,7 +70,7 @@ export const ShowAction = ({ selectedAction, handleClose, myName, addNodeByPath,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<ActionComponent myName={myName} addNodeByPath={addNodeByPath} mode={mode} groups={groups} selectedGroup={selectedGroup} />
|
||||
<ActionComponent myName={myName} addNodeByPath={addNodeByPath} mode={mode} groups={groups} selectedGroup={selectedGroup} files={selectedAction?.files || []}/>
|
||||
</Box>
|
||||
{/* <LoadingSnackbar
|
||||
open={false}
|
||||
|
||||
@@ -1,211 +1,285 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, ButtonBase, CircularProgress, MenuItem, Select, styled } from "@mui/material";
|
||||
import { DisplayCode } from "../components/DisplayCode";
|
||||
import { DisplayCodeResponse } from "../components/DisplayCodeResponse";
|
||||
|
||||
import beautify from "js-beautify";
|
||||
import {
|
||||
Box,
|
||||
ButtonBase,
|
||||
CircularProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { fileToBase64 } from "../utils";
|
||||
import { openToast } from "../components/openToast";
|
||||
import Button from "../components/Button";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { services } from "../constants";
|
||||
import { privateServices, services } from "../constants";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
export const Label = styled("label")(
|
||||
({ theme }) => `
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
`
|
||||
);
|
||||
const uid = new ShortUniqueId({ length: 10 });
|
||||
|
||||
export const formatResponse = (code) => {
|
||||
return beautify.js(code, {
|
||||
indent_size: 2, // Number of spaces for indentation
|
||||
space_in_empty_paren: true, // Add spaces inside parentheses
|
||||
});
|
||||
};
|
||||
export const PUBLISH_MULTIPLE_QDN_RESOURCES = () => {
|
||||
export const Label = styled("label")`
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
export const PUBLISH_MULTIPLE_QDN_RESOURCES = ({
|
||||
files: initialFiles = [],
|
||||
addNodeByPath,
|
||||
myName,
|
||||
mode,
|
||||
groups,
|
||||
selectedGroup,
|
||||
}) => {
|
||||
const [files, setFiles] = useState(initialFiles);
|
||||
const [requestData, setRequestData] = useState({
|
||||
service: "DOCUMENT",
|
||||
identifier: "test-identifier",
|
||||
});
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
maxFiles: 1,
|
||||
onDrop: async (acceptedFiles) => {
|
||||
const fileSelected = acceptedFiles[0];
|
||||
if (fileSelected) {
|
||||
setFile(fileSelected);
|
||||
}
|
||||
},
|
||||
service: mode === "private" ? "DOCUMENT_PRIVATE" : "DOCUMENT",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [file, setFile] = useState(null);
|
||||
const [responseData, setResponseData] = useState(
|
||||
formatResponse(`{
|
||||
"type": "PUBLISH_MULTIPLE_QDN_RESOURCES",
|
||||
"timestamp": 1697286687406,
|
||||
"reference": "3jU9WpEPAvu9iL3cMfVd2AUmn9AijJRzkGCxVtXfpuUFZubM8AFDcbk5XA9m5AhPfsbMDFkSDzPJnkjeLA5GA59E",
|
||||
"fee": "0.01000000",
|
||||
"signature": "3QJ1EUvX3rskVNaP3RWvJwb9DsGgHPvneWqBWS62PCcuCj5N4Ei9Tr4nFj4nQeMqMU2qNkVD3Sb59e7iUWkawH3s",
|
||||
"txGroupId": 0,
|
||||
"approvalStatus": "NOT_REQUIRED",
|
||||
"creatorAddress": "Qhxphh7g5iNtxAyLLpPMZzp4X85yf2tVam",
|
||||
"voterPublicKey": "C5spuNU1BAHZDEkxF3wnrAPRDuNrVceaDJ6tDKitenko",
|
||||
"pollName": "A test poll 3",
|
||||
"optionIndex": 1
|
||||
}`)
|
||||
);
|
||||
const [response, setResponse] = useState("");
|
||||
|
||||
const codePollName = `
|
||||
await qortalRequest({
|
||||
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
|
||||
service: "${requestData?.service}",
|
||||
identifier: "${requestData?.identifier}", // optional
|
||||
data64: ${requestData?.data64 ? `"${requestData?.data64}"` : "empty"}, // base64 string. Remove this param if you are putting in a FILE object
|
||||
file: ${file ? 'FILE OBJECT' : "empty"} // File Object. Remove this param if you are putting in a base64 string.
|
||||
});
|
||||
`.trim();
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
multiple: true,
|
||||
onDrop: (acceptedFiles) => {
|
||||
// append new files to state
|
||||
setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
},
|
||||
});
|
||||
|
||||
const executeQortalRequest = async () => {
|
||||
try {
|
||||
// Utility: derive filename parts & identifier
|
||||
const makeMeta = (file) => {
|
||||
const ext = file.name.includes(".")
|
||||
? file.name.split(".").pop()
|
||||
: "";
|
||||
const title = file.name
|
||||
.split(".")
|
||||
.slice(0, -1)
|
||||
.join(".")
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, 20) || "untitled";
|
||||
const filename = ext ? `${title}.${ext}` : title;
|
||||
const base = title.toLowerCase();
|
||||
const prefix =
|
||||
mode === "public"
|
||||
? "pub"
|
||||
: mode === "private"
|
||||
? "pvt"
|
||||
: `grp-${selectedGroup}`;
|
||||
const identifier = `${prefix}-q-manager-${base}`;
|
||||
return { filename, identifier };
|
||||
};
|
||||
|
||||
const executeMulti = async () => {
|
||||
const promise = (async () => {
|
||||
if (mode === "group" && !selectedGroup)
|
||||
throw new Error("Please select a group");
|
||||
if (!requestData?.service) throw new Error("Please select a service");
|
||||
setIsLoading(true);
|
||||
let account = await qortalRequest({
|
||||
|
||||
// 1) build resources array
|
||||
const resources = [];
|
||||
const localMetaByIdentifier = new Map();
|
||||
for (const file of files) {
|
||||
const { filename, identifier } = makeMeta(file);
|
||||
const data64 = await fileToBase64(file);
|
||||
localMetaByIdentifier.set(identifier, {
|
||||
filename,
|
||||
mimeType: file?.type || "application/octet-stream",
|
||||
sizeInBytes: Number(file?.size) || 0,
|
||||
});
|
||||
|
||||
if (mode === "group") {
|
||||
// group‐encrypt
|
||||
const encrypted = await qortalRequest({
|
||||
action: "ENCRYPT_QORTAL_GROUP_DATA",
|
||||
data64,
|
||||
groupId: selectedGroup,
|
||||
});
|
||||
resources.push({
|
||||
service: requestData.service,
|
||||
identifier,
|
||||
filename,
|
||||
mimeType: file.type,
|
||||
data64: encrypted,
|
||||
externalEncrypt: true,
|
||||
});
|
||||
} else if (mode === "private") {
|
||||
// private‐encrypt
|
||||
const encrypted = await qortalRequest({
|
||||
action: "ENCRYPT_DATA_WITH_SHARING_KEY",
|
||||
data64,
|
||||
});
|
||||
resources.push({
|
||||
service: requestData.service,
|
||||
identifier,
|
||||
filename,
|
||||
mimeType: file.type,
|
||||
data64: encrypted,
|
||||
});
|
||||
} else {
|
||||
// public
|
||||
resources.push({
|
||||
service: requestData.service,
|
||||
identifier,
|
||||
filename,
|
||||
mimeType: file.type,
|
||||
file, // raw File object
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2) send multi-publish request
|
||||
const result = await qortalRequest({
|
||||
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
|
||||
service: requestData?.service,
|
||||
identifier: requestData?.identifier,
|
||||
file,
|
||||
data64: requestData?.data64
|
||||
resources,
|
||||
});
|
||||
|
||||
setResponseData(formatResponse(JSON.stringify(account)));
|
||||
} catch (error) {
|
||||
setResponseData(formatResponse(JSON.stringify(error)));
|
||||
console.error(error);
|
||||
// 3) update tree exactly like single publish
|
||||
for (const res of Array.isArray(result) ? result : [result]) {
|
||||
const { identifier, service, filename, mimeType } = res;
|
||||
const localMeta = localMetaByIdentifier.get(identifier) || {};
|
||||
const resolvedFilename = filename || localMeta?.filename || identifier;
|
||||
const resolvedMimeType =
|
||||
mimeType || localMeta?.mimeType || "application/octet-stream";
|
||||
const resolvedSizeValue =
|
||||
res?.sizeInBytes ??
|
||||
res?.size ??
|
||||
res?.dataSize ??
|
||||
res?.createdSize ??
|
||||
res?.totalSize ??
|
||||
localMeta?.sizeInBytes;
|
||||
const parsedSize = Number(resolvedSizeValue);
|
||||
const sizeInBytes =
|
||||
Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : undefined;
|
||||
const groupEntry =
|
||||
mode === "group"
|
||||
? {
|
||||
group: selectedGroup,
|
||||
groupName: groups?.find((g) => g.groupId === selectedGroup)?.groupName,
|
||||
}
|
||||
: {};
|
||||
|
||||
addNodeByPath(undefined, {
|
||||
type: "file",
|
||||
name: resolvedFilename,
|
||||
mimeType: resolvedMimeType,
|
||||
...(sizeInBytes !== undefined ? { sizeInBytes } : {}),
|
||||
qortalName: myName,
|
||||
identifier,
|
||||
service,
|
||||
...groupEntry,
|
||||
});
|
||||
}
|
||||
|
||||
setFiles([]); // clear selection
|
||||
return result;
|
||||
})();
|
||||
|
||||
await openToast(promise, {
|
||||
loading: "Publishing files...",
|
||||
success: "All files published!",
|
||||
error: (e) => `Publish failed: ${e.message || e.error || e}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const final = await promise;
|
||||
setResponse(JSON.stringify(final, null, 2));
|
||||
} catch (e) {
|
||||
setResponse(JSON.stringify(e, null, 2));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
const handleChange = (e) => {
|
||||
setRequestData((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
}}
|
||||
>
|
||||
<div className="card">
|
||||
<div className="message-row">
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Label>Service</Label>
|
||||
<Select
|
||||
size="small"
|
||||
labelId="label-select-category"
|
||||
id="id-select-category"
|
||||
value={requestData?.service}
|
||||
displayEmpty
|
||||
onChange={(e) => setRequestData((prev)=> {
|
||||
return {
|
||||
...prev,
|
||||
service: e.target.value
|
||||
}
|
||||
})}
|
||||
sx={{
|
||||
width: '300px'
|
||||
value={requestData.service}
|
||||
onChange={(e) =>
|
||||
setRequestData((p) => ({ ...p, service: e.target.value }))
|
||||
}
|
||||
sx={{ width: 200 }}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
backgroundColor: "#1f2530",
|
||||
color: "#ffffff",
|
||||
backgroundImage: "none",
|
||||
maxHeight: 380,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value={0}>
|
||||
<em>No service selected</em>
|
||||
</MenuItem>
|
||||
{services?.map((service) => {
|
||||
return (
|
||||
<MenuItem key={service.name} value={service.name}>
|
||||
{`${service.name} - max ${service.sizeLabel}`}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{(mode === "private" ? privateServices : services).map((s) => (
|
||||
<MenuItem key={s.name} value={s.name}>
|
||||
{s.name} (max {s.sizeLabel})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Label>Index option</Label>
|
||||
<input
|
||||
type="text"
|
||||
className="custom-input"
|
||||
placeholder="identifier"
|
||||
value={requestData.identifier}
|
||||
name="identifier"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<button {...getRootProps()} style={{
|
||||
width: '150px'
|
||||
}}>
|
||||
<input {...getInputProps()} />
|
||||
Select file
|
||||
</button>
|
||||
{file && (
|
||||
<ButtonBase sx={{
|
||||
width: '150px'
|
||||
}} onClick={()=> {
|
||||
setFile(null)
|
||||
}}>Remove file</ButtonBase>
|
||||
)}
|
||||
<Label>Base64 string</Label>
|
||||
<input
|
||||
type="text"
|
||||
className="custom-input"
|
||||
name="data64"
|
||||
value={requestData?.data64}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button
|
||||
name="Publish"
|
||||
bgColor="#309ed1"
|
||||
onClick={executeQortalRequest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: "20px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: "50%",
|
||||
}}
|
||||
>
|
||||
<h3>Request</h3>
|
||||
<DisplayCode codeBlock={codePollName} language="javascript" />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: "50%",
|
||||
}}
|
||||
>
|
||||
<h3>Response</h3>
|
||||
{isLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<DisplayCodeResponse
|
||||
codeBlock={responseData}
|
||||
language="javascript"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
mb: 2,
|
||||
p: 2,
|
||||
border: "2px dashed #555",
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Typography>
|
||||
Click or drag files here to add more ({files.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography>
|
||||
{files.length} file{files.length !== 1 ? "s" : ""} selected:
|
||||
</Typography>
|
||||
<ul>
|
||||
{files.map((f, i) => (
|
||||
<li key={i}>
|
||||
{f.name}{" "}
|
||||
<ButtonBase
|
||||
onClick={() =>
|
||||
setFiles((prev) => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</ButtonBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
name="Publish all"
|
||||
bgColor="#309ed1"
|
||||
onClick={executeMulti}
|
||||
disabled={files.length === 0 || isLoading}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6">Response</Typography>
|
||||
{isLoading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{ background: "#222", color: "#ddd", p: 2, borderRadius: 2 }}
|
||||
>
|
||||
{response}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,15 +8,12 @@ import {
|
||||
Typography,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import { DisplayCode } from "../components/DisplayCode";
|
||||
import { DisplayCodeResponse } from "../components/DisplayCodeResponse";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
import Button from "../components/Button";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { privateServices, services } from "../constants";
|
||||
import { fileToBase64 } from "../utils";
|
||||
import toast from 'react-hot-toast';
|
||||
import { openToast } from "../components/openToast";
|
||||
|
||||
const uid = new ShortUniqueId({ length: 10 });
|
||||
@@ -33,7 +30,9 @@ export const Label = styled("label")(
|
||||
|
||||
export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile, updateByPath , groups, selectedGroup}) => {
|
||||
const [requestData, setRequestData] = useState({
|
||||
service: existingFile?.service || mode === 'private' ? "DOCUMENT_PRIVATE" : "DOCUMENT"
|
||||
service:
|
||||
existingFile?.service ||
|
||||
(mode === "private" ? "DOCUMENT_PRIVATE" : "DOCUMENT"),
|
||||
});
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
@@ -55,6 +54,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
const promise = (async () => {
|
||||
try {
|
||||
if (!file) throw new Error('Please select a file')
|
||||
if (!requestData?.service) throw new Error("Please select a service")
|
||||
if(!selectedGroup) throw new Error('Please select a group')
|
||||
const findGroup = groups?.find((group)=> group.groupId === selectedGroup)
|
||||
if(!findGroup) throw new Error('Cannot find group')
|
||||
@@ -95,6 +95,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
updateByPath({
|
||||
...existingFile,
|
||||
mimeType: file?.type,
|
||||
sizeInBytes: file?.size,
|
||||
});
|
||||
setFile("");
|
||||
return true; // Success
|
||||
@@ -106,6 +107,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
type: "file",
|
||||
name: filename,
|
||||
mimeType: file?.type,
|
||||
sizeInBytes: file?.size,
|
||||
qortalName: myName,
|
||||
identifier: constructedIdentifier,
|
||||
service: requestData?.service,
|
||||
@@ -140,6 +142,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
const promise = (async () => {
|
||||
try {
|
||||
if (!file) return;
|
||||
if (!requestData?.service) throw new Error("Please select a service")
|
||||
setIsLoading(true);
|
||||
|
||||
const fileExtension = file?.name?.includes(".") ? file.name.split(".").pop() : "";
|
||||
@@ -174,6 +177,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
updateByPath({
|
||||
...existingFile,
|
||||
mimeType: file?.type,
|
||||
sizeInBytes: file?.size,
|
||||
});
|
||||
setFile("");
|
||||
return true; // Success
|
||||
@@ -185,6 +189,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
type: "file",
|
||||
name: filename,
|
||||
mimeType: file?.type,
|
||||
sizeInBytes: file?.size,
|
||||
qortalName: myName,
|
||||
identifier: constructedIdentifier,
|
||||
service: requestData?.service,
|
||||
@@ -216,6 +221,9 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
setIsLoading(true);
|
||||
|
||||
const promise = (async () => {
|
||||
if (!requestData?.service) {
|
||||
throw new Error("Please select a service");
|
||||
}
|
||||
const fileExtension = file?.name?.includes(".")
|
||||
? file.name.split(".").pop()
|
||||
: "";
|
||||
@@ -245,6 +253,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
updateByPath({
|
||||
...existingFile,
|
||||
mimeType: file?.type,
|
||||
sizeInBytes: file?.size,
|
||||
});
|
||||
setFile("");
|
||||
return;
|
||||
@@ -256,6 +265,7 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
type: "file",
|
||||
name: filename,
|
||||
mimeType: file?.type,
|
||||
sizeInBytes: file?.size,
|
||||
qortalName: myName,
|
||||
identifier: constructedIdentifier,
|
||||
service: requestData?.service,
|
||||
@@ -322,13 +332,15 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
backgroundColor: "#333333", // Background of the dropdown
|
||||
color: "#ffffff", // Text color
|
||||
backgroundColor: "#1f2530",
|
||||
color: "#ffffff",
|
||||
backgroundImage: "none",
|
||||
maxHeight: 380,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value={0}>
|
||||
<MenuItem disabled value=''>
|
||||
<em>No service selected</em>
|
||||
</MenuItem>
|
||||
{(mode === 'private' ? privateServices : services)?.map((service) => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from "react";
|
||||
import "./button.css";
|
||||
|
||||
const Button = ({ name, onClick, bgColor }) => {
|
||||
const Button = ({ name, onClick, bgColor, disabled = false }) => {
|
||||
return (
|
||||
<div className="button-container">
|
||||
<button
|
||||
style={{ backgroundColor: bgColor }}
|
||||
className="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
|
||||
@@ -25,3 +25,10 @@
|
||||
.button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
570
src/storage.ts
570
src/storage.ts
@@ -1,12 +1,69 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
base64ToUint8Array,
|
||||
objectToBase64,
|
||||
uint8ArrayToObject,
|
||||
} from "./utils";
|
||||
import { privateServices, services } from "./constants";
|
||||
|
||||
const DB_NAME = "FileSystemDB";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "fileSystemQManager";
|
||||
const LOCAL_STORAGE_PREFIX = "q-manager-filesystem-v1";
|
||||
|
||||
const QDN_STRUCTURE_IDENTIFIER = "q-manager-filesystem-v1";
|
||||
const QDN_STRUCTURE_FILENAME = "q-manager-filesystem-v1.txt";
|
||||
const LEGACY_QDN_BACKUP_IDENTIFIER = "qmgr-db-backup";
|
||||
const STORAGE_RECORD_VERSION = 2;
|
||||
|
||||
const getLocalStorageKey = (address) => `${LOCAL_STORAGE_PREFIX}:${address}`;
|
||||
|
||||
const isValidFileSystemQManager = (data) => {
|
||||
return (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
Array.isArray(data.public) &&
|
||||
Array.isArray(data.private) &&
|
||||
data.group !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
const getNow = () => Date.now();
|
||||
|
||||
const toStorageRecord = (fileSystemQManager, updatedAt = getNow()) => ({
|
||||
version: STORAGE_RECORD_VERSION,
|
||||
updatedAt,
|
||||
data: fileSystemQManager,
|
||||
});
|
||||
|
||||
const parseFileSystemRecord = (raw) => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
if (isValidFileSystemQManager(raw?.data)) {
|
||||
return {
|
||||
data: raw.data,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (isValidFileSystemQManager(raw)) {
|
||||
return {
|
||||
data: raw,
|
||||
updatedAt: Number(raw.updatedAt || raw._updatedAt) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const initializeDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("FileSystemDB", 1);
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains("fileSystemQManager")) {
|
||||
// Create object store with `address` as the keyPath
|
||||
db.createObjectStore("fileSystemQManager", { keyPath: "address" });
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "address" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,47 +72,472 @@ const initializeDB = () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const saveFileSystemQManagerToLocalStorage = (
|
||||
fileSystemQManager,
|
||||
address,
|
||||
updatedAt = getNow()
|
||||
) => {
|
||||
if (!address) return;
|
||||
|
||||
export const saveFileSystemQManagerToDB = async ( fileSystemQManager, address) => {
|
||||
try {
|
||||
const db = await initializeDB();
|
||||
const transaction = db.transaction("fileSystemQManager", "readwrite");
|
||||
const store = transaction.objectStore("fileSystemQManager");
|
||||
|
||||
// Save or update data for the specific address
|
||||
store.put({ address, data: fileSystemQManager });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve(`FileSystemQManager for address ${address} saved successfully`);
|
||||
transaction.onerror = (event) => reject(event.target.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error saving fileSystemQManager to IndexedDB:", error);
|
||||
}
|
||||
try {
|
||||
const key = getLocalStorageKey(address);
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify(toStorageRecord(fileSystemQManager, updatedAt))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error saving fileSystemQManager to localStorage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileSystemQManagerRecordFromLocalStorage = (address) => {
|
||||
if (!address) return null;
|
||||
|
||||
try {
|
||||
const key = getLocalStorageKey(address);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
const parsed = JSON.parse(stored);
|
||||
return parseFileSystemRecord(parsed);
|
||||
} catch (error) {
|
||||
console.error("Error reading fileSystemQManager from localStorage:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileSystemQManagerFromLocalStorage = (address) => {
|
||||
const record = getFileSystemQManagerRecordFromLocalStorage(address);
|
||||
return record?.data || null;
|
||||
};
|
||||
|
||||
export const saveFileSystemQManagerToDB = async (
|
||||
fileSystemQManager,
|
||||
address,
|
||||
updatedAt = getNow()
|
||||
) => {
|
||||
if (!address) throw new Error("Address is required to save filesystem.");
|
||||
|
||||
try {
|
||||
const db = await initializeDB();
|
||||
const transaction = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
store.put({
|
||||
address,
|
||||
...toStorageRecord(fileSystemQManager, updatedAt),
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
transaction.oncomplete = () => resolve(true);
|
||||
transaction.onerror = () => resolve(false);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error saving fileSystemQManager to IndexedDB:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getFileSystemQManagerRecordFromDB = async (address) => {
|
||||
if (!address) return null;
|
||||
|
||||
try {
|
||||
const db = await initializeDB();
|
||||
const transaction = db.transaction(STORE_NAME, "readonly");
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(address);
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
if (event.target.result) {
|
||||
resolve(parseFileSystemRecord(event.target.result));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
request.onerror = (event) => reject(event.target.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving fileSystemQManager from IndexedDB:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileSystemQManagerFromDB = async (address) => {
|
||||
const record = await getFileSystemQManagerRecordFromDB(address);
|
||||
return record?.data || null;
|
||||
};
|
||||
|
||||
export const saveFileSystemQManagerEverywhere = async (
|
||||
fileSystemQManager,
|
||||
address
|
||||
) => {
|
||||
if (!address) throw new Error("Address is required to save filesystem.");
|
||||
const updatedAt = getNow();
|
||||
|
||||
const dbSaved = await saveFileSystemQManagerToDB(
|
||||
fileSystemQManager,
|
||||
address,
|
||||
updatedAt
|
||||
);
|
||||
|
||||
// Keep localStorage as a backup snapshot and fallback path.
|
||||
if (dbSaved) {
|
||||
saveFileSystemQManagerToLocalStorage(fileSystemQManager, address, updatedAt);
|
||||
return {
|
||||
updatedAt,
|
||||
primary: "indexeddb",
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
saveFileSystemQManagerToLocalStorage(fileSystemQManager, address, updatedAt);
|
||||
return {
|
||||
updatedAt,
|
||||
primary: "localstorage",
|
||||
fallbackUsed: true,
|
||||
};
|
||||
|
||||
export const getFileSystemQManagerFromDB = async (address) => {
|
||||
try {
|
||||
const db = await initializeDB();
|
||||
const transaction = db.transaction("fileSystemQManager", "readonly");
|
||||
const store = transaction.objectStore("fileSystemQManager");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(address);
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
if (event.target.result) {
|
||||
resolve(event.target.result.data);
|
||||
} else {
|
||||
resolve(null); // No data found for this address
|
||||
}
|
||||
};
|
||||
request.onerror = (event) => reject(event.target.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving fileSystemQManager from IndexedDB:", error);
|
||||
};
|
||||
|
||||
export const getPersistedFileSystemQManager = async (address) => {
|
||||
if (!address) return null;
|
||||
|
||||
const dbRecord = await getFileSystemQManagerRecordFromDB(address);
|
||||
const localRecord = getFileSystemQManagerRecordFromLocalStorage(address);
|
||||
|
||||
if (dbRecord?.data && localRecord?.data) {
|
||||
const dbUpdatedAt = Number(dbRecord.updatedAt) || 0;
|
||||
const localUpdatedAt = Number(localRecord.updatedAt) || 0;
|
||||
|
||||
// Prefer the newer snapshot. If equal/unknown, keep IndexedDB as source of truth.
|
||||
if (localUpdatedAt > dbUpdatedAt) {
|
||||
await saveFileSystemQManagerToDB(
|
||||
localRecord.data,
|
||||
address,
|
||||
localUpdatedAt || getNow()
|
||||
);
|
||||
return localRecord.data;
|
||||
}
|
||||
|
||||
saveFileSystemQManagerToLocalStorage(
|
||||
dbRecord.data,
|
||||
address,
|
||||
dbUpdatedAt || getNow()
|
||||
);
|
||||
return dbRecord.data;
|
||||
}
|
||||
|
||||
if (dbRecord?.data) {
|
||||
const synchronizedAt = Number(dbRecord.updatedAt) || getNow();
|
||||
saveFileSystemQManagerToLocalStorage(
|
||||
dbRecord.data,
|
||||
address,
|
||||
synchronizedAt
|
||||
);
|
||||
return dbRecord.data;
|
||||
}
|
||||
|
||||
// IndexedDB unavailable/empty: fallback to local backup and heal DB opportunistically.
|
||||
if (localRecord?.data) {
|
||||
const synchronizedAt = Number(localRecord.updatedAt) || getNow();
|
||||
await saveFileSystemQManagerToDB(localRecord.data, address, synchronizedAt);
|
||||
return localRecord.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const publishFileSystemQManagerToQDN = async ({
|
||||
fileSystemQManager,
|
||||
}) => {
|
||||
if (!fileSystemQManager) {
|
||||
throw new Error("No filesystem data available to publish");
|
||||
}
|
||||
|
||||
const plainData64 = await objectToBase64(fileSystemQManager);
|
||||
const encryptedData = await qortalRequest({
|
||||
action: "ENCRYPT_DATA",
|
||||
data64: plainData64,
|
||||
});
|
||||
|
||||
if (!encryptedData) {
|
||||
throw new Error("Failed to encrypt filesystem data");
|
||||
}
|
||||
|
||||
return qortalRequest({
|
||||
action: "PUBLISH_QDN_RESOURCE",
|
||||
service: "DOCUMENT_PRIVATE",
|
||||
identifier: QDN_STRUCTURE_IDENTIFIER,
|
||||
filename: QDN_STRUCTURE_FILENAME,
|
||||
data64: encryptedData,
|
||||
});
|
||||
};
|
||||
|
||||
export const importFileSystemQManagerFromQDN = async (name) => {
|
||||
if (!name) {
|
||||
throw new Error("Qortal name is required to import from QDN");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/arbitrary/DOCUMENT_PRIVATE/${name}/${QDN_STRUCTURE_IDENTIFIER}?encoding=base64`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch filesystem resource from QDN (${response.status})`);
|
||||
}
|
||||
|
||||
const encryptedData = await response.text();
|
||||
if (!encryptedData) {
|
||||
throw new Error("No filesystem data found in QDN resource");
|
||||
}
|
||||
|
||||
const decryptedData = await qortalRequest({
|
||||
action: "DECRYPT_DATA",
|
||||
encryptedData,
|
||||
});
|
||||
|
||||
if (!decryptedData) {
|
||||
throw new Error("Could not decrypt filesystem data");
|
||||
}
|
||||
|
||||
const decryptedBytes = base64ToUint8Array(decryptedData);
|
||||
const parsed = uint8ArrayToObject(decryptedBytes);
|
||||
if (!isValidFileSystemQManager(parsed)) {
|
||||
throw new Error("QDN filesystem data is invalid");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizeResourceList = (payload) => {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.resources)) return payload.resources;
|
||||
if (Array.isArray(payload?.data)) return payload.data;
|
||||
return [];
|
||||
};
|
||||
|
||||
const fetchResourcesFromEndpoint = async (url) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return [];
|
||||
const json = await response.json();
|
||||
return normalizeResourceList(json);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getResourceField = (resource, keys) => {
|
||||
for (const key of keys) {
|
||||
if (resource?.[key] !== undefined && resource?.[key] !== null) {
|
||||
return resource[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isDeleteTombstoneResource = (resource) => {
|
||||
const rawSize = getResourceField(resource, [
|
||||
"size",
|
||||
"sizeInBytes",
|
||||
"dataSize",
|
||||
"createdSize",
|
||||
"totalSize",
|
||||
]);
|
||||
const numericSize = Number(rawSize);
|
||||
if (!Number.isFinite(numericSize)) return false;
|
||||
return numericSize <= 1;
|
||||
};
|
||||
|
||||
const normalizeDiscoveredResource = (resource, ownerName) => {
|
||||
const identifier = getResourceField(resource, [
|
||||
"identifier",
|
||||
"id",
|
||||
"resourceId",
|
||||
]);
|
||||
if (!identifier || typeof identifier !== "string") return null;
|
||||
|
||||
const identifierLower = identifier.toLowerCase();
|
||||
if (!identifierLower.includes("q-manager")) return null;
|
||||
if (identifier === QDN_STRUCTURE_IDENTIFIER) return null;
|
||||
if (identifier === LEGACY_QDN_BACKUP_IDENTIFIER) return null;
|
||||
if (isDeleteTombstoneResource(resource)) return null;
|
||||
|
||||
const service = getResourceField(resource, ["service", "serviceName"]);
|
||||
if (!service || typeof service !== "string") return null;
|
||||
|
||||
const qortalName = getResourceField(resource, ["name", "qortalName"]) || ownerName;
|
||||
if (!qortalName) return null;
|
||||
|
||||
const filename = getResourceField(resource, ["filename", "fileName"]);
|
||||
const title = getResourceField(resource, ["title"]);
|
||||
const mimeType = getResourceField(resource, [
|
||||
"mimeType",
|
||||
"mime",
|
||||
"contentType",
|
||||
"mediaType",
|
||||
]);
|
||||
const groupId = getResourceField(resource, ["groupId", "group", "groupid"]);
|
||||
const rawSize = getResourceField(resource, [
|
||||
"sizeInBytes",
|
||||
"size",
|
||||
"dataSize",
|
||||
"createdSize",
|
||||
"totalSize",
|
||||
]);
|
||||
const parsedSize = Number(rawSize);
|
||||
const sizeInBytes =
|
||||
Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : undefined;
|
||||
|
||||
return {
|
||||
type: "file",
|
||||
name: filename || title || identifier,
|
||||
displayName: filename || title || identifier,
|
||||
...(filename ? { filename } : {}),
|
||||
...(title ? { title } : {}),
|
||||
identifier,
|
||||
service,
|
||||
qortalName,
|
||||
mimeType: mimeType || "application/octet-stream",
|
||||
groupId: Number(groupId) || 0,
|
||||
...(sizeInBytes !== undefined ? { sizeInBytes } : {}),
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
|
||||
const buildGetResourcePropertiesPayloads = (resource) => {
|
||||
const basePayload = {
|
||||
action: "GET_QDN_RESOURCE_PROPERTIES",
|
||||
service: resource?.service,
|
||||
identifier: resource?.identifier,
|
||||
};
|
||||
const ownerName = resource?.qortalName || resource?.name;
|
||||
if (!ownerName) return [basePayload];
|
||||
return [
|
||||
{ ...basePayload, name: ownerName },
|
||||
{ ...basePayload, qortalName: ownerName },
|
||||
basePayload,
|
||||
];
|
||||
};
|
||||
|
||||
const hydrateDiscoveredResourceFromProperties = async (resource) => {
|
||||
if (!resource?.service || !resource?.identifier) return resource;
|
||||
if (resource?.filename) return resource;
|
||||
if (typeof qortalRequest !== "function") return resource;
|
||||
|
||||
const payloads = buildGetResourcePropertiesPayloads(resource);
|
||||
let properties = null;
|
||||
|
||||
for (const payload of payloads) {
|
||||
try {
|
||||
const response = await qortalRequest(payload);
|
||||
if (response === undefined || response === null) continue;
|
||||
properties = response;
|
||||
break;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!properties || typeof properties !== "object") return resource;
|
||||
|
||||
const filename = getResourceField(properties, ["filename", "fileName"]);
|
||||
const mimeType = getResourceField(properties, [
|
||||
"mimeType",
|
||||
"mime",
|
||||
"contentType",
|
||||
"mediaType",
|
||||
]);
|
||||
const rawSize = getResourceField(properties, [
|
||||
"sizeInBytes",
|
||||
"size",
|
||||
"dataSize",
|
||||
"createdSize",
|
||||
"totalSize",
|
||||
]);
|
||||
const parsedSize = Number(rawSize);
|
||||
const sizeInBytes =
|
||||
Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : undefined;
|
||||
|
||||
const next = { ...resource };
|
||||
if (filename) {
|
||||
next.filename = filename;
|
||||
if (!next.displayName || next.displayName === next.identifier) {
|
||||
next.displayName = filename;
|
||||
}
|
||||
if (!next.name || next.name === next.identifier) {
|
||||
next.name = filename;
|
||||
}
|
||||
}
|
||||
if (mimeType && (!next.mimeType || next.mimeType === "application/octet-stream")) {
|
||||
next.mimeType = mimeType;
|
||||
}
|
||||
if (sizeInBytes !== undefined && next.sizeInBytes === undefined) {
|
||||
next.sizeInBytes = sizeInBytes;
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
export const discoverQManagerResourcesByName = async (name) => {
|
||||
if (!name) {
|
||||
throw new Error("Qortal name is required to discover published resources");
|
||||
}
|
||||
|
||||
const encodedName = encodeURIComponent(name);
|
||||
const discoveredMap = new Map();
|
||||
|
||||
const sharedQuery = "reverse=true&limit=0&offset=0&includemetadata=true";
|
||||
const broadEndpoints = [
|
||||
`/arbitrary/resources/search?name=${encodedName}&${sharedQuery}`,
|
||||
`/arbitrary/resources?name=${encodedName}&${sharedQuery}`,
|
||||
];
|
||||
|
||||
const broadResults = await Promise.all(
|
||||
broadEndpoints.map(fetchResourcesFromEndpoint)
|
||||
);
|
||||
|
||||
for (const list of broadResults) {
|
||||
for (const resource of list) {
|
||||
const normalized = normalizeDiscoveredResource(resource, name);
|
||||
if (!normalized) continue;
|
||||
const key = `${normalized.service}|${normalized.identifier}|${normalized.qortalName}`;
|
||||
discoveredMap.set(key, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredMap.size === 0) {
|
||||
const allServices = Array.from(
|
||||
new Set([...services, ...privateServices].map((item) => item.name))
|
||||
);
|
||||
|
||||
const serviceResults = await Promise.all(
|
||||
allServices.map((service) =>
|
||||
fetchResourcesFromEndpoint(
|
||||
`/arbitrary/resources/search?name=${encodedName}&service=${encodeURIComponent(
|
||||
service
|
||||
)}&${sharedQuery}`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const list of serviceResults) {
|
||||
for (const resource of list) {
|
||||
const normalized = normalizeDiscoveredResource(resource, name);
|
||||
if (!normalized) continue;
|
||||
const key = `${normalized.service}|${normalized.identifier}|${normalized.qortalName}`;
|
||||
discoveredMap.set(key, normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const discoveredResources = Array.from(discoveredMap.values());
|
||||
if (discoveredResources.length === 0) {
|
||||
return discoveredResources;
|
||||
}
|
||||
|
||||
const hydratedResources = await Promise.all(
|
||||
discoveredResources.map((resource) =>
|
||||
hydrateDiscoveredResourceFromProperties(resource)
|
||||
)
|
||||
);
|
||||
|
||||
return hydratedResources;
|
||||
};
|
||||
|
||||
184
src/utils.ts
184
src/utils.ts
@@ -1,15 +1,12 @@
|
||||
export function objectToBase64(obj: Object) {
|
||||
// Step 1: Convert the object to a JSON string
|
||||
export function objectToBase64(obj: unknown): Promise<string> {
|
||||
const jsonString = JSON.stringify(obj)
|
||||
// Step 2: Create a Blob from the JSON string
|
||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
// Remove 'data:application/json;base64,' prefix
|
||||
const base64 = reader.result.replace(
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const localReader = new FileReader()
|
||||
localReader.onloadend = () => {
|
||||
if (typeof localReader.result === 'string') {
|
||||
const base64 = localReader.result.replace(
|
||||
'data:application/json;base64,',
|
||||
''
|
||||
)
|
||||
@@ -18,76 +15,74 @@ export function objectToBase64(obj: Object) {
|
||||
reject(new Error('Failed to read the Blob as a base64-encoded string'))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
reject(reader.error)
|
||||
localReader.onerror = () => {
|
||||
reject(localReader.error ?? new Error('Failed to read the file'))
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
localReader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function base64ToUint8Array(base64: string) {
|
||||
const binaryString = atob(base64)
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64)
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
return bytes
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function uint8ArrayToObject(uint8Array: Uint8Array) {
|
||||
// Decode the byte array using TextDecoder
|
||||
const decoder = new TextDecoder()
|
||||
const jsonString = decoder.decode(uint8Array)
|
||||
export function uint8ArrayToObject<T = unknown>(uint8Array: Uint8Array): T {
|
||||
const decoder = new TextDecoder()
|
||||
const jsonString = decoder.decode(uint8Array)
|
||||
return JSON.parse(jsonString) as T
|
||||
}
|
||||
|
||||
// Convert the JSON string back into an object
|
||||
const obj = JSON.parse(jsonString)
|
||||
export const handleImportClick = async (): Promise<string> => {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.accept = '.base64,.txt'
|
||||
|
||||
return obj
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
fileInput.onchange = () => {
|
||||
const file = fileInput.files?.[0]
|
||||
if (!file) {
|
||||
reject(new Error('No file selected'))
|
||||
return
|
||||
}
|
||||
|
||||
const localReader = new FileReader()
|
||||
localReader.onload = () => {
|
||||
if (typeof localReader.result === 'string') {
|
||||
resolve(localReader.result)
|
||||
} else {
|
||||
reject(new Error('Invalid file content'))
|
||||
}
|
||||
}
|
||||
localReader.onerror = () => {
|
||||
reject(localReader.error ?? new Error('Error reading file'))
|
||||
}
|
||||
|
||||
export const handleImportClick = async () => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.base64,.txt';
|
||||
localReader.readAsText(file)
|
||||
}
|
||||
|
||||
// Create a promise to handle file selection and reading synchronously
|
||||
return await new Promise((resolve, reject) => {
|
||||
fileInput.onchange = () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) {
|
||||
reject(new Error('No file selected'));
|
||||
return;
|
||||
}
|
||||
fileInput.click()
|
||||
})
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
resolve(e.target.result); // Resolve with the file content
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Error reading file'));
|
||||
};
|
||||
class Semaphore {
|
||||
private count: number
|
||||
private waiting: Array<() => void>
|
||||
|
||||
reader.readAsText(file); // Read the file as text (Base64 string)
|
||||
};
|
||||
|
||||
// Trigger the file input dialog
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Semaphore {
|
||||
constructor(count) {
|
||||
constructor(count: number) {
|
||||
this.count = count
|
||||
this.waiting = []
|
||||
}
|
||||
acquire() {
|
||||
return new Promise(resolve => {
|
||||
|
||||
acquire(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
if (this.count > 0) {
|
||||
this.count--
|
||||
resolve()
|
||||
@@ -96,43 +91,56 @@ export function base64ToUint8Array(base64: string) {
|
||||
}
|
||||
})
|
||||
}
|
||||
release() {
|
||||
|
||||
release(): void {
|
||||
if (this.waiting.length > 0) {
|
||||
const resolve = this.waiting.shift()
|
||||
resolve()
|
||||
if (resolve) resolve()
|
||||
} else {
|
||||
this.count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let semaphore = new Semaphore(1)
|
||||
let reader = new FileReader()
|
||||
const semaphore = new Semaphore(1)
|
||||
let reader: FileReader | null = new FileReader()
|
||||
|
||||
export const fileToBase64 = (file) => new Promise(async (resolve, reject) => {
|
||||
if (!reader) {
|
||||
reader = new FileReader()
|
||||
}
|
||||
export const fileToBase64 = async (file: Blob): Promise<string> => {
|
||||
await semaphore.acquire()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result
|
||||
if (typeof dataUrl === "string") {
|
||||
const base64String = dataUrl.split(',')[1]
|
||||
|
||||
try {
|
||||
if (!reader) {
|
||||
reader = new FileReader()
|
||||
}
|
||||
|
||||
const currentReader = reader as FileReader
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
currentReader.onload = () => {
|
||||
const dataUrl = currentReader.result
|
||||
if (typeof dataUrl === 'string') {
|
||||
const base64String = dataUrl.split(',')[1]
|
||||
if (base64String === undefined) {
|
||||
reject(new Error('Invalid data URL'))
|
||||
return
|
||||
}
|
||||
resolve(base64String)
|
||||
} else {
|
||||
reject(new Error('Invalid data URL'))
|
||||
}
|
||||
}
|
||||
|
||||
currentReader.onerror = () => {
|
||||
reject(currentReader.error ?? new Error('Failed to read file'))
|
||||
}
|
||||
|
||||
currentReader.readAsDataURL(file)
|
||||
})
|
||||
} finally {
|
||||
if (reader) {
|
||||
reader.onload = null
|
||||
reader.onerror = null
|
||||
resolve(base64String)
|
||||
} else {
|
||||
reader.onload = null
|
||||
reader.onerror = null
|
||||
reject(new Error('Invalid data URL'))
|
||||
}
|
||||
semaphore.release()
|
||||
}
|
||||
reader.onerror = (error) => {
|
||||
reader.onload = null
|
||||
reader.onerror = null
|
||||
reject(error)
|
||||
semaphore.release()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,5 +4,16 @@ import react from "@vitejs/plugin-react";
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "",
|
||||
base: "./",
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: true,
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/.vscode/**',
|
||||
'**/dist/**'
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user