Major update - v0.2.0 - see CHANGELOG for changes.

This commit is contained in:
2026-02-27 18:05:03 -08:00
parent c50aafff86
commit ff44291955
15 changed files with 2945 additions and 700 deletions

58
CHANGELOG.md Normal file
View 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
View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "QManager",
"private": true,
"version": "0.0.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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}

View File

@@ -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") {
// groupencrypt
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") {
// privateencrypt
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>
);
};

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -25,3 +25,10 @@
.button:focus {
outline: none;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: none;
box-shadow: none;
}

View File

@@ -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;
};

View File

@@ -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()
}
})
}

View File

@@ -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/**'
],
},
},
});