├── .env
├── src
├── react-app-env.d.ts
├── Components
│ ├── FileList
│ │ ├── FileListSublist
│ │ │ ├── FileListSublist.css
│ │ │ └── FileListSublist.tsx
│ │ ├── FileListEmptyMessage.css
│ │ ├── FileListEmptyMessage.tsx
│ │ ├── FileList.css
│ │ └── FileList.tsx
│ ├── Breadcrumb
│ │ ├── BreadcrumbText.css
│ │ └── BreadcrumbText.tsx
│ ├── Dialogs
│ │ ├── dialogTypes.ts
│ │ ├── Dialogs.tsx
│ │ ├── Move
│ │ │ └── Move.tsx
│ │ ├── Copy
│ │ │ └── Copy.tsx
│ │ ├── Content
│ │ │ └── Content.tsx
│ │ ├── CreateFile
│ │ │ └── CreateFile.tsx
│ │ ├── CreateFolder
│ │ │ └── CreateFolder.tsx
│ │ ├── Media
│ │ │ └── Media.tsx
│ │ ├── Rename
│ │ │ └── Rename.tsx
│ │ ├── UploadFile
│ │ │ └── UploadFile.tsx
│ │ ├── Settings
│ │ │ └── Settings.tsx
│ │ ├── ChooseLocation
│ │ │ └── ChooseLocation.tsx
│ │ ├── Edit
│ │ │ └── Edit.tsx
│ │ └── Menu
│ │ │ └── Menu.tsx
│ ├── File
│ │ ├── File.css
│ │ ├── FileSublist
│ │ │ └── FileSublist.tsx
│ │ └── File.tsx
│ ├── ContextMenu
│ │ ├── ContextMenu.css
│ │ ├── ContextMenuActions
│ │ │ ├── CopyAction.tsx
│ │ │ ├── RenameAction.tsx
│ │ │ ├── MoveAction.tsx
│ │ │ ├── RemoveAction.tsx
│ │ │ ├── ZipAction.tsx
│ │ │ ├── EditAction.tsx
│ │ │ ├── ExtractAction.tsx
│ │ │ ├── OpenInNewTabAction.tsx
│ │ │ ├── SettingsAction.tsx
│ │ │ ├── UploadFileAction.tsx
│ │ │ ├── CreateFileAction.tsx
│ │ │ ├── CreateFolderAction.tsx
│ │ │ ├── ChooseLocationAction.tsx
│ │ │ ├── DownloadAction.tsx
│ │ │ └── OpenAction.tsx
│ │ └── ContextMenu.tsx
│ ├── Loader
│ │ └── Loader.tsx
│ ├── FileUploader
│ │ ├── UploadFileList.tsx
│ │ └── FileUploader.tsx
│ ├── Navbar
│ │ ├── ThreeDotsMenu.tsx
│ │ └── Navbar.tsx
│ ├── Notification
│ │ ├── DynamicSnackbar.tsx
│ │ └── NotificationBar.tsx
│ └── HistoryHandler
│ │ └── HistoryHandler.tsx
├── Api
│ ├── types.ts
│ ├── ApiCache.ts
│ ├── contentTypes.ts
│ ├── Item.ts
│ └── ApiHandler.ts
├── Reducers
│ ├── errorReducer.ts
│ ├── loadingReducer.ts
│ ├── currentBlobReducer.ts
│ ├── pathReducer.ts
│ ├── uploadReducer.ts
│ ├── reducer.ts
│ ├── settingsReducer.ts
│ ├── accountReducer.ts
│ ├── dialogsReducer.ts
│ └── itemsReducer.ts
├── App.test.tsx
├── config.ts
├── index.css
├── index.tsx
├── Actions
│ ├── actionTypes.ts
│ └── Actions.ts
├── App.tsx
├── types
│ └── solid-file-client.d.ts
└── serviceWorker.ts
├── public
├── favicon.ico
├── 404.md
├── manifest.json
├── index.html
└── vendor
│ └── plyr
│ └── plyr.svg
├── images
└── Screenshot.png
├── cypress.json
├── cypress
├── fixtures
│ └── example.json
├── integration
│ ├── test_setup_spec.js
│ ├── file_spec.js
│ └── zip_spec.js
├── support
│ ├── index.js
│ └── commands.js
└── plugins
│ └── index.js
├── delete_css_storage.js
├── .gitignore
├── tsconfig.json
├── .github
└── workflows
│ └── e2e.yml
├── README.md
└── package.json
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_VERSION=$npm_package_version
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Otto-AA/solid-filemanager/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/images/Screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Otto-AA/solid-filemanager/HEAD/images/Screenshot.png
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseUrl": "http://localhost:3000",
3 | "experimentalSessionAndOrigin": true
4 | }
5 |
--------------------------------------------------------------------------------
/src/Components/FileList/FileListSublist/FileListSublist.css:
--------------------------------------------------------------------------------
1 | .FileListSublist {
2 | overflow: auto;
3 | max-height: 20em;
4 | }
--------------------------------------------------------------------------------
/src/Api/types.ts:
--------------------------------------------------------------------------------
1 | import { FileItem, FolderItem } from "./Item";
2 |
3 | export interface FolderItems {
4 | files: FileItem[],
5 | folders: FolderItem[]
6 | };
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
6 |
--------------------------------------------------------------------------------
/delete_css_storage.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 |
3 | // delete community-solid-server storage
4 | // as it adds new account on every test
5 | fs.rmSync('./community-solid-server', { recursive: true, force: true });
--------------------------------------------------------------------------------
/src/Components/Breadcrumb/BreadcrumbText.css:
--------------------------------------------------------------------------------
1 | .BreadcrumbText {
2 | }
3 |
4 | .BreadcrumbText span {
5 | cursor: pointer;
6 | text-overflow: ellipsis;
7 | }
8 |
9 | .BreadcrumbText span:hover {
10 | color: #efefef;
11 | }
--------------------------------------------------------------------------------
/src/Components/FileList/FileListEmptyMessage.css:
--------------------------------------------------------------------------------
1 | .FileListEmptyMessage {
2 | margin: 5px 10px;
3 | padding: 20px;
4 | display: block;
5 | border-radius: 5px;
6 | background: #efefef;
7 | color: #333;
8 | font-size: 15px;
9 | }
--------------------------------------------------------------------------------
/public/404.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /404.html
3 | ---
4 | # 404 - Not found
5 | The requested site was not found.
6 |
7 | You can go back to the github repository [here](https://github.com/Otto-AA/solid-filemanager), or open the file manager [here](https://otto-aa.github.io/solid-filemanager/).
--------------------------------------------------------------------------------
/src/Components/FileList/FileListEmptyMessage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './FileListEmptyMessage.css';
3 |
4 | export default function FileListEmptyMessage() {
5 | return (
6 |
7 | No files in this folder
8 |
9 | );
10 | };
--------------------------------------------------------------------------------
/cypress/integration/test_setup_spec.js:
--------------------------------------------------------------------------------
1 | describe('The Home Page', () => {
2 | it('can use createRandomAccount', () => {
3 | cy.createRandomAccount().then(console.log)
4 | })
5 |
6 | it('can use login command', () => {
7 | cy.createRandomAccount().as('user')
8 | cy.get('@user').then(user => cy.login(user))
9 | })
10 | })
--------------------------------------------------------------------------------
/src/Reducers/errorReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, SET_ERROR_MESSAGE } from "../Actions/actionTypes";
2 |
3 | export const errorMessage = (state = '', action: Action): typeof state => {
4 | switch(action.type) {
5 | case SET_ERROR_MESSAGE:
6 | return action.value;
7 | default:
8 | return state;
9 | }
10 | };
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Solid Filemanager",
3 | "name": "Solid Filemanager",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#fff",
14 | "background_color": "#2196f3"
15 | }
16 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/dialogTypes.ts:
--------------------------------------------------------------------------------
1 | export interface DialogDispatchProps {
2 | handleClose(event: DialogButtonClickEvent): void;
3 | handleSubmit?(event: DialogButtonClickEvent, options?: Record): void;
4 | }
5 |
6 | export interface DialogStateProps {
7 | open: boolean;
8 | }
9 |
10 | export type DialogButtonClickEvent = React.MouseEvent;
--------------------------------------------------------------------------------
/src/Components/File/File.css:
--------------------------------------------------------------------------------
1 | .File {
2 | cursor: pointer;
3 | float: left;
4 | display: block;
5 | width: 100%;
6 | user-select: none;
7 | }
8 |
9 | .File:hover {
10 | cursor: pointer;
11 | background: #fafafa;
12 | }
13 |
14 | .File[data-selected=true] {
15 | background-color: #e8f0fe;
16 |
17 | }
18 |
19 | .File[data-selected=true],
20 | .File[data-selected=true] > li > div > span {
21 | color: #1967d2;
22 | }
--------------------------------------------------------------------------------
/src/Components/FileList/FileList.css:
--------------------------------------------------------------------------------
1 | .FileList {
2 | overflow: auto
3 | }
4 |
5 | .FileList .File .filename > span {
6 | text-overflow: ellipsis;
7 | overflow: hidden;
8 | white-space: nowrap;
9 | }
10 |
11 | @media (min-width : 600px) {
12 | .FileList .File {
13 | float: left;
14 | width: 33.3%;
15 | }
16 | }
17 | @media (min-width : 1024px) {
18 | .FileList .File {
19 | float: left;
20 | width: 25%;
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Reducers/loadingReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, DISPLAY_LOADING, STOP_LOADING } from "../Actions/actionTypes";
2 |
3 | const initialLoadingState: boolean = false;
4 |
5 | export const loading = (state = initialLoadingState, action: Action): boolean => {
6 | switch(action.type) {
7 | case DISPLAY_LOADING:
8 | return true;
9 | case STOP_LOADING:
10 | return false;
11 | default:
12 | return state;
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenu.css:
--------------------------------------------------------------------------------
1 | .XXXXXXXXXXXContextMenu {
2 | max-width: 200px;
3 | position: absolute;
4 | display: block;
5 | background: green;
6 | border: 1px solid black;
7 | padding: 10px;
8 | text-align: left;
9 | }
10 |
11 | .ContextMenu ul {
12 | margin: 0;
13 | padding: 0;
14 | list-style: none;
15 | }
16 |
17 | .ContextMenu ul li {
18 | padding: 10px 20px;
19 | border-bottom: 1px solid #000;
20 | }
21 |
22 | .ContextMenu ul li:last-child {
23 | border: none;
24 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # build
9 | /build
10 |
11 | # testing
12 | /coverage
13 | /cypress/videos
14 | /cypress/screenshots
15 | /cypress/downloads
16 |
17 | # community-solid-server
18 | /community-solid-server
19 |
20 | # misc
21 | .DS_Store
22 | .env.local
23 | .env.development.local
24 | .env.test.local
25 | .env.production.local
26 |
27 | npm-debug.log*
28 | yarn-debug.log*
29 | yarn-error.log*
--------------------------------------------------------------------------------
/src/Reducers/currentBlobReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, SET_LOADED_BLOB, RESET_LOADED_BLOB } from "../Actions/actionTypes";
2 |
3 | export const blob = (state: string|null = null, action: Action): typeof state => {
4 | switch(action.type) {
5 | case SET_LOADED_BLOB:
6 | return URL.createObjectURL(action.value);
7 | case RESET_LOADED_BLOB:
8 | if (state !== null)
9 | URL.revokeObjectURL(state);
10 | return null;
11 | default:
12 | return state;
13 | }
14 | };
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": false,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "preserve"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import thunk from 'redux-thunk';
5 | import { Provider } from 'react-redux';
6 | import { createStore, applyMiddleware } from 'redux'
7 | import reducer from './Reducers/reducer'
8 |
9 | const store = createStore(reducer, applyMiddleware(thunk));
10 |
11 | it('renders without crashing', () => {
12 | const div = document.createElement('div');
13 | ReactDOM.render(
14 |
15 |
16 |
17 | , div);
18 | ReactDOM.unmountComponentAtNode(div);
19 | });
20 |
--------------------------------------------------------------------------------
/src/Reducers/pathReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, ENTER_FOLDER, SET_PATH, MOVE_FOLDER_UPWARDS } from "../Actions/actionTypes";
2 |
3 |
4 | const initialPath: string[] = [];
5 |
6 | export const path = (state = initialPath, action: Action): typeof initialPath => {
7 | switch(action.type) {
8 | case ENTER_FOLDER:
9 | return [...state, action.value];
10 | case MOVE_FOLDER_UPWARDS:
11 | return action.value > 0 ?
12 | state.slice(0, -action.value)
13 | : state;
14 | case SET_PATH:
15 | return [...action.value];
16 | default:
17 | return state;
18 | }
19 | };
--------------------------------------------------------------------------------
/src/Reducers/uploadReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, SET_UPLOAD_FILE_LIST, SET_UPLOAD_FILE_PROGRESS } from "../Actions/actionTypes";
2 |
3 | const initialUploadState = {
4 | fileList: null as FileList|null,
5 | progress: 0,
6 | };
7 |
8 | export const upload = (state = initialUploadState, action: Action): typeof initialUploadState => {
9 | switch(action.type) {
10 | case SET_UPLOAD_FILE_LIST:
11 | return { ...state, fileList: action.value as FileList };
12 | case SET_UPLOAD_FILE_PROGRESS:
13 | return { ...state, progress: action.value as number };
14 | default:
15 | return state;
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/.github/workflows/e2e.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 |
8 | jobs:
9 | e2e:
10 |
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: '22'
18 | cache: 'npm'
19 |
20 | - name: Install dependencies
21 | run: npm ci
22 | - run: npm run test:e2e
23 |
24 | - name: Archive test videos
25 | if: always()
26 | uses: actions/upload-artifact@v4
27 | with:
28 | name: cypress-artifacts
29 | path: |
30 | cypress/screenshots
31 | cypress/videos
32 | retention-days: 3
--------------------------------------------------------------------------------
/src/Components/Loader/Loader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles';
3 | import CircularProgress from '@material-ui/core/CircularProgress';
4 | import Grid from '@material-ui/core/Grid';
5 |
6 | const styles = (theme: Theme) => createStyles({
7 | progress: {
8 | margin: theme.spacing() * 10,
9 | },
10 | });
11 |
12 | function Loader(props: LoaderProps) {
13 | return (
14 |
15 |
16 |
17 | );
18 | }
19 |
20 | interface LoaderProps extends WithStyles {};
21 |
22 | export default withStyles(styles)(Loader);
23 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
--------------------------------------------------------------------------------
/src/Reducers/reducer.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { account } from './accountReducer';
3 | import { blob } from './currentBlobReducer';
4 | import { visibleDialogs, contextMenu } from './dialogsReducer';
5 | import { errorMessage } from './errorReducer';
6 | import { items } from './itemsReducer';
7 | import { loading } from './loadingReducer';
8 | import { path } from './pathReducer';
9 | import { upload } from './uploadReducer';
10 | import { settings } from './settingsReducer';
11 |
12 | const rootReducer = combineReducers({
13 | account,
14 | blob,
15 | contextMenu,
16 | visibleDialogs,
17 | errorMessage,
18 | items,
19 | loading,
20 | path,
21 | settings,
22 | upload,
23 | });
24 |
25 | export default rootReducer;
26 | export type AppState = ReturnType;
--------------------------------------------------------------------------------
/src/Reducers/settingsReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, TOGGLE_WITH_ACL, TOGGLE_WITH_META } from "../Actions/actionTypes";
2 | import config from "../config";
3 |
4 | interface SettingsState {
5 | withAcl: boolean;
6 | withMeta: boolean;
7 | }
8 |
9 | const initialState: SettingsState = {
10 | withAcl: config.withAcl(),
11 | withMeta: config.withMeta(),
12 | };
13 |
14 | export const settings = (state = initialState, action: Action): SettingsState => {
15 | switch(action.type) {
16 | case TOGGLE_WITH_ACL:
17 | config.toggleWithAcl();
18 | return { ...state, withAcl: config.withAcl() }
19 | case TOGGLE_WITH_META:
20 | config.toggleWithMeta();
21 | return { ...state, withMeta: config.withMeta() }
22 | default:
23 | return state;
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************************
3 | // This example plugins/index.js can be used to load plugins
4 | //
5 | // You can change the location of this file or turn off loading
6 | // the plugins file with the 'pluginsFile' configuration option.
7 | //
8 | // You can read more here:
9 | // https://on.cypress.io/plugins-guide
10 | // ***********************************************************
11 |
12 | // This function is called when a project is opened or re-opened (e.g. due to
13 | // the project's config changing)
14 |
15 | /**
16 | * @type {Cypress.PluginConfig}
17 | */
18 | // eslint-disable-next-line no-unused-vars
19 | module.exports = (on, config) => {
20 | // `on` is used to hook into various events Cypress emits
21 | // `config` is the resolved Cypress config
22 | }
23 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | let host: string | null = null;
2 | let withAcl: boolean = localStorage.getItem('config_with_acl') === 'true';
3 | let withMeta: boolean = localStorage.getItem('config_with_meta') === 'true';
4 |
5 | const config = {
6 | getHost() {
7 | return host;
8 | },
9 | setHost(newHost: string) {
10 | host = newHost;
11 | while (host.endsWith('/'))
12 | host = host.slice(0, -1);
13 | },
14 | withAcl() {
15 | return withAcl;
16 | },
17 | toggleWithAcl() {
18 | withAcl = !withAcl
19 | localStorage.setItem('config_with_acl', withAcl ? 'true' : 'false')
20 | },
21 | withMeta() {
22 | return withMeta;
23 | },
24 | toggleWithMeta() {
25 | withMeta = !withMeta
26 | localStorage.setItem('config_with_meta', withMeta ? 'true' : 'false')
27 | },
28 | }
29 |
30 | export default config;
31 |
32 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, "Roboto", "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
5 | -webkit-font-smoothing: antialiased;
6 | -moz-osx-font-smoothing: grayscale;
7 | }
8 |
9 | code {
10 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
11 | }
12 |
13 | *::-webkit-scrollbar-track {
14 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
15 | border-radius: 0;
16 | background-color: #F5F5F5;
17 | cursor: default;
18 | }
19 |
20 | *::-webkit-scrollbar {
21 | width: 8px;
22 | background-color: #F5F5F5;
23 | cursor: default;
24 | }
25 |
26 | *::-webkit-scrollbar-thumb {
27 | border-radius: 0px;
28 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
29 | background-color: #555;
30 | cursor: default;
31 | }
32 |
--------------------------------------------------------------------------------
/src/Api/ApiCache.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-unused-vars
2 | import { Item } from './Item';
3 |
4 | export default class ApiCache {
5 | _data: Record = {};
6 |
7 | /**
8 | * Add data to the cache
9 | */
10 | add(path: string, itemList: Item[]): Item[] {
11 | this._data[path] = itemList;
12 | return itemList;
13 | }
14 |
15 | /**
16 | * Return true if the url is already cached
17 | */
18 | contains(path: string): boolean {
19 | return Object.keys(this._data).includes(path);
20 | }
21 |
22 | /**
23 | * Get the cached data
24 | */
25 | get(path: string): Item[] {
26 | return this._data[path];
27 | }
28 |
29 | /**
30 | * Remove paths from the cache
31 | */
32 | remove(...paths: string[]) {
33 | paths.filter(path => this.contains(path))
34 | .forEach(path => delete this._data[path]);
35 | }
36 |
37 | /**
38 | * Clear the whole cache
39 | */
40 | clear() {
41 | this._data = {};
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Components/FileUploader/UploadFileList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import List from '@material-ui/core/List';
3 | import ListItem from '@material-ui/core/ListItem';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import ListItemText from '@material-ui/core/ListItemText';
6 | import FileIcon from '@material-ui/icons/InsertDriveFile';
7 | import { getHumanFileSize } from '../../Api/Item';
8 |
9 | function UploadFileList(props: UploadFileListProps) {
10 | const { files } = props;
11 | const list = Array.from(files).map((f, i) =>
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
20 | return (
21 |
22 |
23 | {list}
24 |
25 |
26 | );
27 | }
28 |
29 | interface UploadFileListProps {
30 | files: FileList;
31 | }
32 |
33 | export default UploadFileList;
34 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/Dialogs.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DialogMenu from './Menu/Menu';
3 | import DialogSettings from './Settings/Settings';
4 | import DialogContent from './Content/Content';
5 | import DialogMedia from './Media/Media';
6 | import DialogEdit from './Edit/Edit';
7 | import DialogCreateFolder from './CreateFolder/CreateFolder';
8 | import DialogCreateFile from './CreateFile/CreateFile';
9 | import DialogRename from './Rename/Rename';
10 | import DialogMove from './Move/Move';
11 | import DialogCopy from './Copy/Copy';
12 | import DialogUploadFile from './UploadFile/UploadFile';
13 |
14 | // TODO: Consider moving the visibility logic here
15 | function Dialogs() {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | export default Dialogs;
34 |
--------------------------------------------------------------------------------
/src/Components/FileList/FileListSublist/FileListSublist.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import FileSublist from '../../File/FileSublist/FileSublist';
3 | import Loader from '../../Loader/Loader';
4 | import FileListEmptyMessage from '../FileListEmptyMessage';
5 | import './FileListSublist.css';
6 | import { FolderItem } from '../../../Api/Item';
7 |
8 | function FileListSublist(props: OwnProps) {
9 | const { items, isLoading, handleOpenFolder } = props;
10 |
11 | const itemComponents = items.map((item, key) => {
12 | return handleOpenFolder(item)}
16 | handleDoubleClick={() => handleOpenFolder(item)}
17 | key={key} />
18 | });
19 |
20 | return
21 | { isLoading ?
22 | :
23 | itemComponents.length ? itemComponents :
24 | }
25 |
26 | }
27 |
28 | interface OwnProps {
29 | items: FolderItem[];
30 | isLoading: boolean;
31 | handleOpenFolder(folder: FolderItem): void;
32 | }
33 |
34 | export default FileListSublist;
--------------------------------------------------------------------------------
/src/Reducers/accountReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, SET_LOGGED_IN, SET_LOGGED_OUT, SET_HOST, SET_WEB_ID, RESET_HOST, RESET_WEB_ID } from "../Actions/actionTypes";
2 | import config from "../config";
3 |
4 | interface AccountState {
5 | loggedIn: boolean;
6 | host: string | null;
7 | webId: string | null;
8 | }
9 |
10 | const initialState: AccountState = {
11 | loggedIn: false,
12 | host: null,
13 | webId: null
14 | };
15 |
16 | export const account = (state = initialState, action: Action): AccountState => {
17 | switch(action.type) {
18 | case SET_LOGGED_IN:
19 | return { ...state, loggedIn: true };
20 | case SET_LOGGED_OUT:
21 | return { ...state, loggedIn: false };
22 | case SET_HOST:
23 | config.setHost(action.value as string); // TODO
24 | return { ...state, host: action.value as string };
25 | case RESET_HOST:
26 | return { ...state, host: null };
27 | case SET_WEB_ID:
28 | return { ...state, webId: action.value as string|null };
29 | case RESET_WEB_ID:
30 | return { ...state, webId: null };
31 | default:
32 | return state;
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import thunk from 'redux-thunk';
4 | import { Provider } from 'react-redux';
5 | import { createStore, applyMiddleware } from 'redux'
6 | import reducer from './Reducers/reducer'
7 | import * as serviceWorker from './serviceWorker';
8 | import App from './App';
9 | import './index.css';
10 | import 'typeface-roboto';
11 |
12 | const store = createStore(reducer, applyMiddleware(thunk));
13 |
14 | ReactDOM.render(
15 |
16 |
17 |
18 | , document.getElementById('root'));
19 |
20 | // If you want your app to work offline and load faster, you can change
21 | // unregister() to register() below. Note this comes with some pitfalls.
22 | // Learn more about service workers: http://bit.ly/CRA-PWA
23 | // see issue https://github.com/Otto-AA/solid-filemanager/issues/26
24 | serviceWorker.unregister();
25 | /*
26 | serviceWorker.register({
27 | onUpdate: (config) => {
28 | console.group('serviceWorker.onUpdate');
29 | console.log(config);
30 | console.groupEnd();
31 | },
32 | onSuccess: (config) => {
33 | console.group('serviceWorker.onSuccess');
34 | console.log(config);
35 | console.groupEnd();
36 | }
37 | });
38 | */
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/CopyAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import FileCopyIcon from '@material-ui/icons/FileCopy';
7 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { DIALOGS } from '../../../Actions/actionTypes';
10 |
11 | function CopyAction(props: CopyActionProps) {
12 | const { handleClick } = props;
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 | Copy
21 |
22 |
23 | );
24 | }
25 |
26 | interface CopyActionProps {
27 | handleClick(): void;
28 | }
29 |
30 | const mapStateToProps = (state: AppState) => {
31 | return {};
32 | };
33 |
34 | const mapDispatchToProps = (dispatch: MyDispatch) => {
35 | return {
36 | handleClick: () => {
37 | dispatch(openDialog(DIALOGS.COPY));
38 | }
39 | };
40 | };
41 |
42 | export default connect(mapStateToProps, mapDispatchToProps)(CopyAction);
43 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/RenameAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import WrapTextIcon from '@material-ui/icons/WrapText';
7 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { DIALOGS } from '../../../Actions/actionTypes';
10 |
11 | function MoveAction(props: MoveActionProps) {
12 | const { handleClick } = props;
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 | Rename
21 |
22 |
23 | );
24 | }
25 |
26 | interface MoveActionProps {
27 | handleClick(): void;
28 | }
29 |
30 | const mapStateToProps = (state: AppState) => {
31 | return {};
32 | };
33 |
34 | const mapDispatchToProps = (dispatch: MyDispatch) => {
35 | return {
36 | handleClick: () => {
37 | dispatch(openDialog(DIALOGS.RENAME));
38 | }
39 | };
40 | };
41 |
42 | export default connect(mapStateToProps, mapDispatchToProps)(MoveAction);
43 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/MoveAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import HowToVoteIcon from '@material-ui/icons/HowToVote';
7 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { DIALOGS } from '../../../Actions/actionTypes';
10 |
11 | function MoveAction(props: MoveActionProps) {
12 | const { handleClick } = props;
13 |
14 | return (
15 | handleClick()}>
16 |
17 |
18 |
19 |
20 | Move
21 |
22 |
23 | );
24 | }
25 |
26 | interface MoveActionProps {
27 | handleClick(): void;
28 | }
29 |
30 | const mapStateToProps = (state: AppState) => {
31 | return {};
32 | };
33 |
34 | const mapDispatchToProps = (dispatch: MyDispatch) => {
35 | return {
36 | handleClick: () => {
37 | dispatch(openDialog(DIALOGS.MOVE));
38 | }
39 | };
40 | };
41 |
42 | export default connect(mapStateToProps, mapDispatchToProps)(MoveAction);
43 |
--------------------------------------------------------------------------------
/src/Reducers/dialogsReducer.ts:
--------------------------------------------------------------------------------
1 | import { DIALOGS, Action, OPEN_DIALOG, CLOSE_DIALOG, OPEN_CONTEXT_MENU, CLOSE_CONTEXT_MENU } from "../Actions/actionTypes";
2 |
3 | // Initialize state with values of DIALOGS as keys and false (closed) as value
4 | const initialVisibleDialogs: Record = Object.values(DIALOGS)
5 | .map((name: DIALOGS) => ({ [name]: false } as Record))
6 | .reduce((prev, cur) => ({ ...prev, ...cur })) as Record;
7 |
8 | export const visibleDialogs = (state = initialVisibleDialogs, action: Action): typeof initialVisibleDialogs => {
9 | switch (action.type) {
10 | case OPEN_DIALOG:
11 | return { ...state, [action.value]: true };
12 | case CLOSE_DIALOG:
13 | return { ...state, [action.value]: false };
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | const initialContextMenuState = {
20 | open: false,
21 | x: 0,
22 | y: 0,
23 | };
24 |
25 | export const contextMenu = (state = initialContextMenuState, action: Action): typeof initialContextMenuState => {
26 | switch (action.type) {
27 | case OPEN_CONTEXT_MENU:
28 | return {
29 | ...state,
30 | open: true,
31 | x: action.value.x,
32 | y: action.value.y
33 | };
34 | case CLOSE_CONTEXT_MENU:
35 | return { ...state, open: false };
36 | default:
37 | return state;
38 | }
39 | };
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/RemoveAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import { removeItems, MyDispatch } from '../../../Actions/Actions';
5 | import ListItemIcon from '@material-ui/core/ListItemIcon';
6 | import Typography from '@material-ui/core/Typography';
7 | import DeleteIcon from '@material-ui/icons/Delete';
8 | import { Item } from '../../../Api/Item';
9 | import { AppState } from '../../../Reducers/reducer';
10 |
11 | function RemoveAction(props: RemoveActionProps) {
12 | const { handleClick, selectedItems } = props;
13 | return (
14 | handleClick(selectedItems)}>
15 |
16 |
17 |
18 |
19 | Remove
20 |
21 |
22 | );
23 | }
24 |
25 | interface RemoveActionProps {
26 | handleClick(selectedItems: Item[]): void;
27 | selectedItems: Item[];
28 | }
29 |
30 | const mapStateToProps = (state: AppState) => {
31 | return {
32 | selectedItems: state.items.selected
33 | };
34 | };
35 |
36 | const mapDispatchToProps = (dispatch: MyDispatch) => {
37 | return {
38 | handleClick: (selectedItems: Item[]) => {
39 | dispatch(removeItems(selectedItems));
40 | }
41 | };
42 | };
43 |
44 | export default connect(mapStateToProps, mapDispatchToProps)(RemoveAction);
45 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/ZipAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import ArchiveIcon from '@material-ui/icons/Archive';
7 | import { zipAndUpload, MyDispatch } from '../../../Actions/Actions';
8 | import { Item } from '../../../Api/Item';
9 | import { AppState } from '../../../Reducers/reducer';
10 |
11 | function ZipAction(props: ZipActionProps) {
12 | const { handleClick, selectedItems } = props;
13 |
14 | return (
15 | handleClick(selectedItems)}>
16 |
17 |
18 |
19 |
20 | Zip here
21 |
22 |
23 | );
24 | }
25 |
26 | interface ZipActionProps {
27 | handleClick(selectedItems: Item[]): void;
28 | selectedItems: Item[];
29 | }
30 |
31 | const mapStateToProps = (state: AppState) => {
32 | return {
33 | selectedItems: state.items.selected
34 | };
35 | };
36 |
37 | const mapDispatchToProps = (dispatch: MyDispatch) => {
38 | return {
39 | handleClick: (selectedItems: Item[]) => {
40 | dispatch(zipAndUpload(selectedItems));
41 | }
42 | };
43 | };
44 |
45 | export default connect(mapStateToProps, mapDispatchToProps)(ZipAction);
46 |
--------------------------------------------------------------------------------
/src/Api/contentTypes.ts:
--------------------------------------------------------------------------------
1 | import * as mime from 'mime';
2 |
3 | export async function guessContentType(fileName: string, blob?: Blob | string, defaultType = 'text/turtle'): Promise {
4 | const extType = mime.getType(fileName)
5 | if (extType)
6 | return extType;
7 | if (blob instanceof Blob) {
8 | const blobType = await guessFromBlob(blob);
9 | if (blobType)
10 | return blobType;
11 | }
12 | return defaultType;
13 | };
14 |
15 | export function guessFromBlob(blob: Blob): Promise {
16 | const fileReader = new FileReader();
17 | return new Promise(resolve => {
18 | fileReader.onloadend = e => {
19 | if (e && e.target && e.target.readyState === FileReader.DONE) {
20 | const arr = new Uint8Array(e.target.result as ArrayBuffer);
21 | let header = '';
22 | for (let i = 0; i < arr.length; i++)
23 | header += arr[i].toString(16);
24 | resolve(magicNumberToMime(header))
25 | }
26 | };
27 | fileReader.readAsArrayBuffer(blob.slice(0, 4));
28 | });
29 | }
30 |
31 | // TODO: Add more magic numbers
32 | // See https://en.wikipedia.org/wiki/List_of_file_signatures
33 | function magicNumberToMime(num: string): string | undefined {
34 | switch (num) {
35 | case "89504e47":
36 | return "image/png";
37 | case "47494638":
38 | return "image/gif";
39 | case "ffd8ffe0":
40 | case "ffd8ffe1":
41 | case "ffd8ffe2":
42 | case "ffd8ffe3":
43 | case "ffd8ffe8":
44 | return "image/jpeg";
45 | }
46 | return undefined;
47 | }
48 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/EditAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import { loadAndEditFile, MyDispatch } from '../../../Actions/Actions';
5 | import ListItemIcon from '@material-ui/core/ListItemIcon';
6 | import Typography from '@material-ui/core/Typography';
7 | import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
8 | import { Item } from '../../../Api/Item';
9 | import { AppState } from '../../../Reducers/reducer';
10 |
11 | function OpenAction(props: OpenActionProps) {
12 | const { handleClick, selectedItems } = props;
13 | return (
14 | handleClick(selectedItems)}>
15 |
16 |
17 |
18 |
19 | Edit
20 |
21 |
22 | );
23 | }
24 |
25 | interface OpenActionProps {
26 | handleClick(selectedItems: Item[]): void;
27 | selectedItems: Item[];
28 | }
29 |
30 | const mapStateToProps = (state: AppState) => {
31 | return {
32 | selectedItems: state.items.selected
33 | };
34 | };
35 |
36 | const mapDispatchToProps = (dispatch: MyDispatch) => {
37 | return {
38 | handleClick: (selectedItems: Item[]) => {
39 | dispatch(loadAndEditFile(selectedItems[0].name));
40 | }
41 | };
42 | };
43 |
44 | export default connect(mapStateToProps, mapDispatchToProps)(OpenAction);
45 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/ExtractAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import UnarchiveIcon from '@material-ui/icons/Unarchive';
7 | import { extractZipFile, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { Item } from '../../../Api/Item';
10 |
11 | function ExtractAction(props: ExtractActionProps) {
12 | const {handleClick, selectedItems} = props;
13 |
14 | return (
15 | handleClick(selectedItems)}>
16 |
17 |
18 |
19 |
20 | Extract here
21 |
22 |
23 | );
24 | }
25 |
26 | interface ExtractActionProps {
27 | handleClick(selectedItems: Item[]): void;
28 | selectedItems: Item[];
29 | }
30 |
31 | const mapStateToProps = (state: AppState) => {
32 | return {
33 | selectedItems: state.items.selected
34 | };
35 | };
36 |
37 | const mapDispatchToProps = (dispatch: MyDispatch) => {
38 | return {
39 | handleClick: (selectedItems: Item[]) => {
40 | dispatch(extractZipFile(selectedItems[0].name));
41 | }
42 | };
43 | };
44 |
45 | export default connect(mapStateToProps, mapDispatchToProps)(ExtractAction);
46 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/OpenInNewTabAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import { openInNewTab, MyDispatch } from '../../../Actions/Actions';
5 | import ListItemIcon from '@material-ui/core/ListItemIcon';
6 | import Typography from '@material-ui/core/Typography';
7 | import LinkIcon from '@material-ui/icons/Link';
8 | import { Item } from '../../../Api/Item';
9 | import { AppState } from '../../../Reducers/reducer';
10 |
11 | function OpenInNewTabAction(props: OpenInNewTabActionProps) {
12 | const { handleClick, selectedItems } = props;
13 | return (
14 | handleClick(selectedItems)}>
15 |
16 |
17 |
18 |
19 | Open in new Tab
20 |
21 |
22 | );
23 | }
24 |
25 | interface OpenInNewTabActionProps {
26 | handleClick(selectedItems: Item[]): void;
27 | selectedItems: Item[];
28 | }
29 |
30 | const mapStateToProps = (state: AppState) => {
31 | return {
32 | selectedItems: state.items.selected
33 | };
34 | };
35 |
36 | const mapDispatchToProps = (dispatch: MyDispatch) => {
37 | return {
38 | handleClick: (selectedItems: Item[]) => {
39 | dispatch(openInNewTab(selectedItems[0]));
40 | }
41 | };
42 | };
43 |
44 | export default connect(mapStateToProps, mapDispatchToProps)(OpenInNewTabAction);
45 |
--------------------------------------------------------------------------------
/src/Components/File/FileSublist/FileSublist.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ListItem from '@material-ui/core/ListItem';
3 | import ListItemAvatar from '@material-ui/core/ListItemAvatar';
4 | import ListItemText from '@material-ui/core/ListItemText';
5 | import Avatar from '@material-ui/core/Avatar';
6 | import FolderIcon from '@material-ui/icons/Folder';
7 | import FileIcon from '@material-ui/icons/InsertDriveFile';
8 | import blue from '@material-ui/core/colors/blue';
9 | import '../File.css';
10 | import { FileItem, Item } from '../../../Api/Item';
11 |
12 | // TODO: Check main differences between normal File.tsx component
13 | function FileSublist(props: OwnProps) {
14 | const { item, isSelected, handleClick, handleDoubleClick } = props;
15 | const avatarStyle = {
16 | backgroundColor: isSelected ? blue['A200'] : undefined
17 | };
18 | return (
19 |
20 |
21 |
22 |
23 | { (item instanceof FileItem) ? : }
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | interface OwnProps {
33 | item: Item;
34 | isSelected: boolean;
35 | handleClick(): void;
36 | handleDoubleClick(): void;
37 | }
38 |
39 | export default FileSublist;
40 |
41 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/SettingsAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
7 | import { AppState } from '../../../Reducers/reducer';
8 | import { DIALOGS } from '../../../Actions/actionTypes';
9 | import { Settings } from '@material-ui/icons';
10 |
11 | function ChooseLocationAction(props: ChooseLocationActionProps) {
12 | const { handleClick, handleClose } = props;
13 |
14 | const handleCloseAfter = (callback: () => void) => () => {
15 | callback();
16 | handleClose();
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | Settings
26 |
27 |
28 | );
29 | }
30 |
31 | interface ChooseLocationActionProps {
32 | handleClick(): void;
33 | handleClose(): void;
34 | }
35 |
36 | const mapStateToProps = (state: AppState) => {
37 | return {};
38 | };
39 |
40 | const mapDispatchToProps = (dispatch: MyDispatch) => {
41 | return {
42 | handleClick: () => {
43 | dispatch(openDialog(DIALOGS.SETTINGS));
44 | }
45 | };
46 | };
47 |
48 | export default connect(mapStateToProps, mapDispatchToProps)(ChooseLocationAction);
49 |
--------------------------------------------------------------------------------
/src/Components/FileList/FileList.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import File from '../File/File';
4 | import FileListEmptyMessage from './FileListEmptyMessage';
5 | import Loader from '../Loader/Loader';
6 | import './FileList.css';
7 | import { Item } from '../../Api/Item';
8 | import { AppState } from '../../Reducers/reducer';
9 |
10 | class FileList extends Component {
11 | render() {
12 | const { items, isLoading } = this.props;
13 | const itemComponents = items.map((item, key) => {
14 | return ;
15 | });
16 |
17 | return
18 | { isLoading ?
19 | :
20 | itemComponents.length ? itemComponents :
21 | }
22 |
23 | }
24 | }
25 |
26 | interface StateProps {
27 | items: Item[];
28 | isLoading: boolean;
29 | }
30 | interface FileListProps extends StateProps {};
31 |
32 | const mapStateToProps = (state: AppState): StateProps => {
33 | const items = state.items.inCurFolder
34 | .filter(item => filterMatch(item.getDisplayName(), state.items.filter));
35 |
36 | return {
37 | items,
38 | isLoading: state.loading,
39 | };
40 | };
41 |
42 |
43 | const mapDispatchToProps = () => ({});
44 |
45 | const filterMatch = (first: string, second: string) => {
46 | return first.toLocaleLowerCase().match(second.toLocaleLowerCase());
47 | }
48 |
49 | export default connect(mapStateToProps, mapDispatchToProps)(FileList);
50 |
51 |
52 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/UploadFileAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import CloudUploadIcon from '@material-ui/icons/CloudUpload';
7 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { DIALOGS } from '../../../Actions/actionTypes';
10 |
11 | function UploadFileAction(props: UploadFileActionProps) {
12 | const { handleClick, handleClose } = props;
13 |
14 | const handleCloseAfter = (callback: () => void) => () => {
15 | callback();
16 | handleClose();
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | Upload files
26 |
27 |
28 | );
29 | }
30 |
31 | interface UploadFileActionProps {
32 | handleClick(): void;
33 | handleClose(): void;
34 | }
35 |
36 | const mapStateToProps = (state: AppState) => {
37 | return {};
38 | };
39 |
40 | const mapDispatchToProps = (dispatch: MyDispatch) => {
41 | return {
42 | handleClick: () => {
43 | dispatch(openDialog(DIALOGS.UPLOAD_FILE));
44 | }
45 | };
46 | };
47 |
48 | export default connect(mapStateToProps, mapDispatchToProps)(UploadFileAction);
49 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/CreateFileAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import InsertDriveFileIcon from '@material-ui/icons/InsertDriveFile';
7 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { DIALOGS } from '../../../Actions/actionTypes';
10 |
11 | function CreateFileAction(props: CreateFileActionProps) {
12 | const {handleClick, handleClose} = props;
13 |
14 | const handleCloseAfter = (callback: () => void) => () => {
15 | callback();
16 | handleClose();
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | Create file
26 |
27 |
28 | );
29 | }
30 |
31 | interface CreateFileActionProps {
32 | handleClick(): void;
33 | handleClose(): void;
34 | }
35 |
36 | const mapStateToProps = (state: AppState) => {
37 | return {};
38 | };
39 |
40 | const mapDispatchToProps = (dispatch: MyDispatch) => {
41 | return {
42 | handleClick: () => {
43 | dispatch(openDialog(DIALOGS.CREATE_FILE));
44 | }
45 | };
46 | };
47 |
48 | export default connect(mapStateToProps, mapDispatchToProps)(CreateFileAction);
49 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/CreateFolderAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import CreateNewFolderIcon from '@material-ui/icons/CreateNewFolder';
7 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { DIALOGS } from '../../../Actions/actionTypes';
10 |
11 | function CreateFolderAction(props: CreateFolderActionProps) {
12 | const {handleClick, handleClose} = props;
13 |
14 | const handleCloseAfter = (callback: () => void) => () => {
15 | callback();
16 | handleClose();
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | Create folder
26 |
27 |
28 | );
29 | }
30 |
31 | interface CreateFolderActionProps {
32 | handleClick(): void;
33 | handleClose(): void;
34 | }
35 |
36 | const mapStateToProps = (state: AppState) => {
37 | return {};
38 | };
39 |
40 | const mapDispatchToProps = (dispatch: MyDispatch) => {
41 | return {
42 | handleClick: () => {
43 | dispatch(openDialog(DIALOGS.CREATE_FOLDER));
44 | }
45 | };
46 | };
47 |
48 | export default connect(mapStateToProps, mapDispatchToProps)(CreateFolderAction);
49 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/ChooseLocationAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import ListItemIcon from '@material-ui/core/ListItemIcon';
5 | import Typography from '@material-ui/core/Typography';
6 | import FolderSharedIcon from '@material-ui/icons/FolderSharedOutlined';
7 | import { openDialog, MyDispatch } from '../../../Actions/Actions';
8 | import { AppState } from '../../../Reducers/reducer';
9 | import { DIALOGS } from '../../../Actions/actionTypes';
10 |
11 | function ChooseLocationAction(props: ChooseLocationActionProps) {
12 | const { handleClick, handleClose } = props;
13 |
14 | const handleCloseAfter = (callback: () => void) => () => {
15 | callback();
16 | handleClose();
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | Choose root location
26 |
27 |
28 | );
29 | }
30 |
31 | interface ChooseLocationActionProps {
32 | handleClick(): void;
33 | handleClose(): void;
34 | }
35 |
36 | const mapStateToProps = (state: AppState) => {
37 | return {};
38 | };
39 |
40 | const mapDispatchToProps = (dispatch: MyDispatch) => {
41 | return {
42 | handleClick: () => {
43 | dispatch(openDialog(DIALOGS.CHOOSE_LOCATION));
44 | }
45 | };
46 | };
47 |
48 | export default connect(mapStateToProps, mapDispatchToProps)(ChooseLocationAction);
49 |
--------------------------------------------------------------------------------
/src/Reducers/itemsReducer.ts:
--------------------------------------------------------------------------------
1 | import { Item } from "../Api/Item";
2 | import { Action, SET_ITEMS, SELECT_ITEMS, DESELECT_ITEM, FILTER_ITEMS, RESET_FILTER, TOGGLE_SELECTED_ITEM } from "../Actions/actionTypes";
3 |
4 | interface ItemsState {
5 | inCurFolder: Item[];
6 | filter: string;
7 | selected: Item[];
8 | }
9 |
10 | const initialItemsState: ItemsState = {
11 | inCurFolder: [],
12 | filter: '',
13 | selected: [],
14 | };
15 |
16 | export const items = (state = initialItemsState, action: Action): ItemsState => {
17 | switch(action.type) {
18 | case SET_ITEMS:
19 | return { ...state, inCurFolder: action.value as Item[] };
20 | case SELECT_ITEMS:
21 | return { ...state, selected: action.value as Item[] };
22 | case DESELECT_ITEM:
23 | return { ...state, selected: removeItem(state.selected, action.value as Item) };
24 | case TOGGLE_SELECTED_ITEM:
25 | return {
26 | ...state,
27 | selected: state.selected.includes(action.value) ?
28 | removeItem(state.selected, action.value as Item)
29 | : addItem(state.selected, action.value as Item)
30 | };
31 | case FILTER_ITEMS:
32 | return { ...state, filter: action.value as string };
33 | case RESET_FILTER:
34 | return { ...state, filter: '' };
35 | default:
36 | return state;
37 | }
38 | };
39 |
40 | const removeItem = (items: Item[], item: Item) => {
41 | return items.filter(selectedItem => !selectedItem.equals(item));
42 | }
43 |
44 | const addItem = (items: Item[], item: Item) => {
45 | return [...items, item];
46 | }
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/DownloadAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import { downloadItems, MyDispatch } from '../../../Actions/Actions';
5 | import ListItemIcon from '@material-ui/core/ListItemIcon';
6 | import Typography from '@material-ui/core/Typography';
7 | import CloudDownloadIcon from '@material-ui/icons/CloudDownload';
8 | import { FileItem, Item } from '../../../Api/Item';
9 | import { AppState } from '../../../Reducers/reducer';
10 |
11 | function DownloadAction(props: DownloadActionProps) {
12 | const { handleClick, selectedItems } = props;
13 | return (
14 | handleClick(selectedItems)}>
15 |
16 |
17 |
18 |
19 | {(selectedItems.length === 1 && selectedItems[0] instanceof FileItem) ?
20 | 'Download'
21 | : 'Download Zip'
22 | }
23 |
24 |
25 | );
26 | }
27 |
28 | interface DownloadActionProps {
29 | handleClick(selectedItems: Item[]): void;
30 | selectedItems: Item[];
31 | }
32 |
33 | const mapStateToProps = (state: AppState) => {
34 | return {
35 | selectedItems: state.items.selected
36 | };
37 | };
38 |
39 | const mapDispatchToProps = (dispatch: MyDispatch) => {
40 | return {
41 | handleClick: (selectedItems: Item[]) => {
42 | dispatch(downloadItems(selectedItems));
43 | }
44 | };
45 | };
46 |
47 | export default connect(mapStateToProps, mapDispatchToProps)(DownloadAction);
48 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/Move/Move.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { moveItems, closeDialog, MyDispatch } from '../../../Actions/Actions';
4 | import { DialogStateProps } from '../dialogTypes';
5 | import { Item } from '../../../Api/Item';
6 | import { AppState } from '../../../Reducers/reducer';
7 | import { DIALOGS } from '../../../Actions/actionTypes';
8 |
9 | import ChooseLocation from '../ChooseLocation/ChooseLocation';
10 |
11 | function MoveDialog(props: MoveProps) {
12 | const { initialHost, initialPath, selectedItems, open, handleClose, move } = props;
13 |
14 | return move(selectedItems, location)}
21 | />
22 | }
23 |
24 |
25 | interface StateProps extends DialogStateProps {
26 | initialHost: string;
27 | initialPath: string[];
28 | selectedItems: Item[];
29 | }
30 | interface DispatchProps {
31 | handleClose(): void;
32 | move(selectedItems: Item[], { host, path }: { host: string, path: string[] }): void;
33 | }
34 | interface MoveProps extends StateProps, DispatchProps {}
35 |
36 |
37 |
38 | const mapStateToProps = (state: AppState): StateProps => {
39 | return {
40 | open: state.visibleDialogs.MOVE,
41 | initialHost: state.account.host || '',
42 | initialPath: state.path,
43 | selectedItems: state.items.selected,
44 | };
45 | };
46 |
47 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
48 | return {
49 | handleClose: () => {
50 | dispatch(closeDialog(DIALOGS.MOVE));
51 | },
52 | move: (selectedItems, targetLocation) => {
53 | dispatch(moveItems(selectedItems, targetLocation));
54 | },
55 | };
56 | };
57 |
58 | export default connect(mapStateToProps, mapDispatchToProps)(MoveDialog);
--------------------------------------------------------------------------------
/src/Components/Dialogs/Copy/Copy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { copyItems, closeDialog, MyDispatch } from '../../../Actions/Actions';
4 | import { DialogStateProps } from '../dialogTypes';
5 | import { Item } from '../../../Api/Item';
6 | import { AppState } from '../../../Reducers/reducer';
7 | import { DIALOGS } from '../../../Actions/actionTypes';
8 |
9 | import ChooseLocation from '../ChooseLocation/ChooseLocation';
10 |
11 | function CopyDialog(props: CopyProps) {
12 | const { initialHost, initialPath, selectedItems, open, handleClose, copy } = props;
13 |
14 | return copy(selectedItems, location)}
21 | />
22 | }
23 |
24 |
25 | interface StateProps extends DialogStateProps {
26 | initialHost: string;
27 | initialPath: string[];
28 | selectedItems: Item[];
29 | }
30 | interface DispatchProps {
31 | handleClose(): void;
32 | copy(selectedItems: Item[], { host, path }: { host: string, path: string[] }): void;
33 | }
34 | interface CopyProps extends StateProps, DispatchProps {}
35 |
36 |
37 |
38 | const mapStateToProps = (state: AppState): StateProps => {
39 | return {
40 | open: state.visibleDialogs.COPY,
41 | initialHost: state.account.host || '',
42 | initialPath: state.path,
43 | selectedItems: state.items.selected,
44 | };
45 | };
46 |
47 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
48 | return {
49 | handleClose: () => {
50 | dispatch(closeDialog(DIALOGS.COPY));
51 | },
52 | copy: (selectedItems, targetLocation) => {
53 | dispatch(copyItems(selectedItems, targetLocation));
54 | },
55 | };
56 | };
57 |
58 | export default connect(mapStateToProps, mapDispatchToProps)(CopyDialog);
59 |
--------------------------------------------------------------------------------
/src/Actions/actionTypes.ts:
--------------------------------------------------------------------------------
1 | export interface Action {
2 | type: string;
3 | value: V;
4 | };
5 |
6 | export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
7 | export const ENTER_FOLDER = 'ENTER_FOLDER';
8 | export const MOVE_FOLDER_UPWARDS = 'MOVE_FOLDER_UPWARDS';
9 | export const SET_PATH = 'SET_PATH';
10 |
11 | export const SET_LOGGED_IN = 'SET_LOGGED_IN';
12 | export const SET_LOGGED_OUT = 'SET_LOGGED_OUT';
13 | export const RESET_HOST = 'RESET_HOST';
14 | export const SET_HOST = 'SET_HOST'; // TODO: Consider renaming to BASE_URL
15 | export const RESET_WEB_ID = 'RESET_WEB_ID';
16 | export const SET_WEB_ID = 'SET_WEB_ID';
17 |
18 | export const TOGGLE_WITH_ACL = 'TOGGLE_WITH_ACL';
19 | export const TOGGLE_WITH_META = 'TOGGLE_WITH_META';
20 |
21 | export const SET_ITEMS = 'SET_ITEMS';
22 |
23 | export const SELECT_ITEMS = 'SELECT_ITEMS';
24 | export const TOGGLE_SELECTED_ITEM = 'TOGGLE_SELECTED_ITEM';
25 | export const DESELECT_ITEM = 'DESELECT_ITEM';
26 |
27 | export const FILTER_ITEMS = 'FILTER_ITEMS';
28 | export const RESET_FILTER = 'REMOVE_FILTER';
29 |
30 | export const DISPLAY_LOADING = 'DISPLAY_LOADING';
31 | export const STOP_LOADING = 'STOP_LOADING';
32 |
33 | export const RESET_LOADED_BLOB = 'RESET_LOADED_BLOB';
34 | export const SET_LOADED_BLOB = 'SET_LOADED_BLOB';
35 |
36 | export const SET_UPLOAD_FILE_PROGRESS = 'SET_UPLOAD_FILE_PROGRESS';
37 | export const SET_UPLOAD_FILE_LIST = 'SET_UPLOAD_FILE_LIST';
38 |
39 | export const OPEN_CONTEXT_MENU = 'OPEN_CONTEXT_MENU';
40 | export const CLOSE_CONTEXT_MENU = 'CLOSE_CONTEXT_MENU';
41 |
42 | export const OPEN_DIALOG = 'OPEN_DIALOG';
43 | export const CLOSE_DIALOG = 'CLOSE_DIALOG';
44 |
45 | export enum DIALOGS {
46 | CHOOSE_LOCATION = 'CHOOSE_LOCATION',
47 | CREATE_FOLDER = 'CREATE_FOLDER',
48 | CREATE_FILE = 'CREATE_FILE',
49 | UPLOAD_FILE = 'UPLOAD_FILE',
50 | RENAME = 'RENAME',
51 | MOVE = 'MOVE',
52 | COPY = 'COPY',
53 | CONTENT = 'CONTENT',
54 | MEDIA = 'MEDIA',
55 | EDIT = 'EDIT',
56 | CONTEXT_MENU = 'CONTEXT_MENU',
57 | SETTINGS = 'SETTINGS',
58 | };
--------------------------------------------------------------------------------
/src/Components/FileUploader/FileUploader.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, createRef } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import UploadFileList from './UploadFileList';
4 |
5 | class FileUploader extends Component {
6 | inputRef: React.RefObject = createRef();
7 |
8 | handleReset(event: React.MouseEvent | React.TouchEvent): void {
9 | const inputElement = this.inputRef.current;
10 | if (inputElement) {
11 | inputElement.value = '';
12 | this.props.handleReset(event);
13 | }
14 | }
15 |
16 | render() {
17 | const { fileList, handleSelectedFiles } = this.props;
18 | const styles = {
19 | inputfile: {
20 | // TODO: Change this to display none as soon, as the label button works
21 | // display: 'none'
22 | }, inputreset: {
23 | display: (fileList && fileList.length) ? 'inline-flex' : 'none'
24 | }
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 | {/*
32 | Select Files
33 | */}
34 |
35 |
36 |
37 | Clear
38 |
39 |
40 | { fileList && }
41 |
42 | );
43 | }
44 | }
45 |
46 | interface FileUploadProps {
47 | fileList: FileList|null;
48 | handleReset(event: React.MouseEvent | React.TouchEvent): void;
49 | handleSelectedFiles(event: React.ChangeEvent): void;
50 | }
51 |
52 | export default FileUploader;
53 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenuActions/OpenAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuItem from '@material-ui/core/MenuItem';
3 | import { connect } from 'react-redux';
4 | import { loadAndDisplayFile, displaySelectedMediaFile, loadAndEditFile, enterFolderByItem, MyDispatch } from '../../../Actions/Actions';
5 | import ListItemIcon from '@material-ui/core/ListItemIcon';
6 | import Typography from '@material-ui/core/Typography';
7 | import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
8 | import { FileItem, FolderItem, Item } from '../../../Api/Item';
9 | import { AppState } from '../../../Reducers/reducer';
10 |
11 | function OpenAction(props: OpenActionProps) {
12 | const { handleClick, selectedItems } = props;
13 | return (
14 | handleClick(selectedItems)}>
15 |
16 |
17 |
18 |
19 | Open
20 |
21 |
22 | );
23 | }
24 |
25 | interface OpenActionProps {
26 | handleClick(selectedItems: Item[]): void;
27 | selectedItems: Item[];
28 | }
29 |
30 | const mapStateToProps = (state: AppState) => {
31 | return {
32 | selectedItems: state.items.selected
33 | };
34 | };
35 |
36 | const mapDispatchToProps = (dispatch: MyDispatch) => {
37 | return {
38 | handleClick: (selectedItems: Item[]) => {
39 | const item = selectedItems[0];
40 |
41 | if (item instanceof FolderItem)
42 | dispatch(enterFolderByItem(item));
43 | else if (item instanceof FileItem) {
44 | if (item.isEditable())
45 | dispatch(loadAndEditFile(item.name));
46 | else if (item.isImage())
47 | dispatch(loadAndDisplayFile(item.name));
48 | else if (item.isMedia())
49 | dispatch(displaySelectedMediaFile());
50 | }
51 | }
52 | };
53 | };
54 |
55 | export default connect(mapStateToProps, mapDispatchToProps)(OpenAction);
56 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | Solid Filemanager
25 |
44 |
45 |
46 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/Components/Navbar/ThreeDotsMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Menu from '@material-ui/core/Menu';
3 | import IconButton from '@material-ui/core/IconButton';
4 | import MoreVertIcon from '@material-ui/icons/MoreVert';
5 | import { connect } from 'react-redux';
6 | import CreateFolderAction from '../ContextMenu/ContextMenuActions/CreateFolderAction';
7 | import CreateFileAction from '../ContextMenu/ContextMenuActions/CreateFileAction';
8 | import UploadFileAction from '../ContextMenu/ContextMenuActions/UploadFileAction';
9 | import ChooseLocationAction from '../ContextMenu/ContextMenuActions/ChooseLocationAction';
10 | import SettingsAction from '../ContextMenu/ContextMenuActions/SettingsAction';
11 |
12 | class ThreeDotsMenu extends React.Component {
13 | state = {
14 | anchorEl: null as HTMLElement|null,
15 | };
16 |
17 | handleClick = (event: React.MouseEvent) => {
18 | this.setState({ anchorEl: event.currentTarget });
19 | };
20 |
21 | handleClose = () => {
22 | this.setState({ anchorEl: null });
23 | };
24 |
25 | render() {
26 | const { anchorEl } = this.state;
27 |
28 | return (
29 |
30 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 |
52 | const mapStateToProps = () => ({});
53 |
54 | const mapDispatchToProps = () => ({});
55 |
56 | export default connect(mapStateToProps, mapDispatchToProps)(ThreeDotsMenu);
57 |
--------------------------------------------------------------------------------
/src/Components/Notification/DynamicSnackbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withStyles, Theme, createStyles, WithStyles } from '@material-ui/core/styles';
3 | import Snackbar from '@material-ui/core/Snackbar';
4 | import IconButton from '@material-ui/core/IconButton';
5 | import CloseIcon from '@material-ui/icons/Close';
6 | import { connect } from 'react-redux';
7 | import { MyDispatch, resetErrorMessage } from '../../Actions/Actions';
8 | import { AppState } from '../../Reducers/reducer';
9 |
10 | const styles = (theme: Theme) => createStyles({
11 | close: {
12 | padding: theme.spacing() / 2,
13 | },
14 | });
15 |
16 | class DynamicSnackbar extends React.Component {
17 | render() {
18 | const { classes, errorMsg, handleClose, open, notificationDuration } = this.props;
19 | return (
20 |
21 | {errorMsg}}
33 | action={[
34 |
35 |
36 | ,
37 | ]}
38 | />
39 |
40 | );
41 | }
42 | }
43 |
44 | interface StateProps {
45 | open: boolean;
46 | errorMsg: string;
47 | notificationDuration: number;
48 | }
49 | interface DispatchProps {
50 | handleClose(): void;
51 | }
52 | interface DynamicSnackbarProps extends StateProps, DispatchProps, WithStyles {}
53 |
54 | const mapStateToProps = (state: AppState): StateProps => {
55 | return {
56 | open: !!state.errorMessage,
57 | errorMsg: state.errorMessage,
58 | notificationDuration: 60000
59 | };
60 | };
61 |
62 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
63 | return {
64 | handleClose: () => {
65 | dispatch(resetErrorMessage());
66 | }
67 | };
68 | };
69 |
70 | export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(DynamicSnackbar));
71 |
72 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import FileList from './Components/FileList/FileList';
3 | import Navbar from './Components/Navbar/Navbar';
4 | import ContextMenu from './Components/ContextMenu/ContextMenu';
5 | import Dialogs from './Components/Dialogs/Dialogs';
6 |
7 | import { MuiThemeProvider as MaterialUI } from '@material-ui/core/styles';
8 | import { createTheme } from '@material-ui/core'
9 | import blue from '@material-ui/core/colors/blue';
10 | import { connect } from 'react-redux';
11 | import { initApp, MyDispatch, closeContextMenu } from './Actions/Actions';
12 | import DynamicSnackbar from './Components/Notification/DynamicSnackbar';
13 | import HistoryHandler from './Components/HistoryHandler/HistoryHandler';
14 |
15 | const theme = createTheme({
16 | palette: {
17 | primary: blue,
18 | },
19 | });
20 |
21 | class App extends Component {
22 |
23 | componentDidMount() {
24 | this.props.init();
25 | };
26 |
27 | render() {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 | }
44 |
45 | interface DispatchProps {
46 | init(): void;
47 | handleHideContextMenu(event: React.MouseEvent): void;
48 | }
49 |
50 | interface AppProps extends DispatchProps {}
51 |
52 | const mapStateToProps = () => ({});
53 |
54 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
55 | return {
56 | init: () => {
57 | dispatch(initApp());
58 | },
59 |
60 | handleHideContextMenu: (event) => {
61 | const element = event.target as HTMLElement;
62 | if (!(element.tagName === 'INPUT' || /label/i.test(element.className))) {
63 | event.preventDefault();
64 | }
65 | dispatch(closeContextMenu());
66 | }
67 | };
68 | };
69 |
70 | export default connect(mapStateToProps, mapDispatchToProps)(App);
71 |
--------------------------------------------------------------------------------
/src/Components/Breadcrumb/BreadcrumbText.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles';
4 | import KeyboardArrowLeftIcon from '@material-ui/icons/KeyboardArrowLeft';
5 | import Button from '@material-ui/core/Button';
6 | import './BreadcrumbText.css';
7 |
8 | const styles = (theme: Theme) => createStyles({
9 | lastPath: {
10 | display: 'block',
11 | [theme.breakpoints.up('sm')]: {
12 | display: 'none'
13 | }
14 | },
15 | paths: {
16 | display: 'none',
17 | [theme.breakpoints.up('sm')]: {
18 | display: 'block',
19 | }
20 | }
21 | });
22 |
23 | class BreadcrumbText extends Component {
24 |
25 | render() {
26 | const { classes, handleClickPath, path, rootTitle, handleGoBack, canGoBack } = this.props;
27 |
28 | const separator = > ;
29 | const rootPath = handleClickPath(-1)} data-index={0}>
30 | { rootTitle } { path.length ? separator : '' }
31 | ;
32 | const lastPath = [...path].pop() || rootTitle;
33 |
34 | const directories = path.map((dir, index) => {
35 | return handleClickPath(index)}>
36 | {dir} { path.length -1 !== index ? separator : '' }
37 |
38 | });
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 | {lastPath}
47 |
48 |
{rootPath} {directories}
49 |
50 | );
51 | }
52 | }
53 |
54 | interface BreadcrumbTextProps extends WithStyles {
55 | handleClickPath(index: number): void;
56 | handleGoBack(): void;
57 | canGoBack: boolean;
58 | path: string[];
59 | rootTitle: string;
60 |
61 | }
62 |
63 | const mapDispatchToProps = () => ({});
64 |
65 | const mapStateToProps = () => ({});
66 |
67 | export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(BreadcrumbText));
68 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](http://unmaintained.tech/)
2 |
3 | **NOTE**: This project has no active maintainer. It may or may not be working, and from time to time stuff will probably get fixed.
4 |
5 | # Solid Filemanager
6 |
7 | This is a file manager for solid pods, based on [this awesome react app](https://github.com/joni2back/react-filemanager/).
8 |
9 | The app is available here: https://otto-aa.github.io/solid-filemanager/ (see below for more options). To use it you need to have a [solid pod](https://solid.inrupt.com/get-a-solid-pod). If the pod is hosted with a newer server version (5.0.0+) you will need to give this app explicit permission to read/write data to your pod (go to .../profile/card#me -> click on "A" in the top -> Add "https://otto-aa.github.io" with read/write access).
10 |
11 | ## Features
12 |
13 | - Navigation through folders
14 | - Upload files
15 | - Copy, remove, move and rename file and folders
16 | - Edit text files (txt, html, ...)
17 | - View media files (video, audio and image files)
18 | - Zip actions (archive and extract)
19 | - Download files
20 | - Open files in a new tab
21 |
22 | 
23 |
24 | ## Hosting the app
25 | It's easy to install your own version of this app. This would have the benefit, that it is independent from this repository, but the disadvantage of getting no updates.
26 |
27 | 1. Go to the [gh-pages](https://github.com/Otto-AA/solid-filemanager/tree/gh-pages) branch
28 | 2. Click on the green download button and save the zip file
29 | 3. Upload the zip to your pod/server (e.g. with this file manager)
30 | 4. Extract the zip file
31 | 5. Open the app in a new tab
32 |
33 | ## Developing
34 | If you want to modify this app, first make sure you've installed git, node and npm. Then enter following commands:
35 |
36 | ```shell
37 | git clone https://github.com/otto-aa/solid-filemanager/ # Downloads the source
38 | cd solid-filemenager # Enter the directory
39 | npm install # Install dependencies
40 | npm start # Start the development app
41 | # Make changes to the source code now
42 | ```
43 |
44 | With `npm build` you can create a static build, but keep in mind that logging into your solid pod requires the app to run on a domain or localhost (and not `file:///C:/.../index.html`). So either deploy it (to your pod, etc.) or run it via localhost (e.g. with npm serve).
45 |
46 | ## Contribute
47 |
48 | Feel free to make contributions by adding features, filing issues, fixing bugs, making suggestions, etc.
49 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/Content/Content.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import DialogTitle from '@material-ui/core/DialogTitle';
7 | import { connect } from 'react-redux';
8 | import { closeDialog, MyDispatch } from '../../../Actions/Actions';
9 | import { DialogStateProps, DialogDispatchProps } from '../dialogTypes';
10 | import { AppState } from '../../../Reducers/reducer';
11 | import { DIALOGS } from '../../../Actions/actionTypes';
12 |
13 | class FormDialog extends Component {
14 |
15 | state = {
16 | lastBlobUrl: null,
17 | content: '...',
18 | loading: false
19 | };
20 |
21 | componentDidUpdate() {
22 | if (this.props.blobUrl !== this.state.lastBlobUrl) {
23 | this.setState({
24 | lastBlobUrl: this.props.blobUrl
25 | });
26 | this.setState({
27 | loading: true
28 | });
29 | }
30 | }
31 |
32 | render() {
33 | const { handleClose, open } = this.props;
34 | return (
35 |
36 |
37 | Viewing file
38 |
39 |
40 |
41 |
42 |
43 | Close
44 |
45 |
46 |
47 |
48 | );
49 | }
50 | }
51 |
52 | interface StateProps extends DialogStateProps {
53 | blobUrl: string | undefined;
54 | }
55 | interface DispatchProps extends DialogDispatchProps {}
56 | interface ContentProps extends StateProps, DispatchProps {}
57 |
58 | const mapStateToProps = (state: AppState): StateProps => {
59 | return {
60 | open: state.visibleDialogs.CONTENT,
61 | blobUrl: state.blob || undefined
62 | };
63 | };
64 |
65 | const mapDispatchToProps = (dispatch: MyDispatch): DialogDispatchProps => {
66 | return {
67 | handleClose: () => {
68 | dispatch(closeDialog(DIALOGS.CONTENT));
69 | }
70 | };
71 | };
72 |
73 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "solid-filemanager",
3 | "version": "1.2.1",
4 | "private": false,
5 | "scripts": {
6 | "predeploy": "npm run build",
7 | "deploy": "gh-pages -d build",
8 | "start": "react-scripts start",
9 | "build": "react-scripts build",
10 | "test": "react-scripts test",
11 | "eject": "react-scripts eject",
12 | "test:e2e": "npm run start-wait && cypress run",
13 | "precss": "node delete_css_storage.js",
14 | "css": "community-solid-server -p 8080 -c @css:config/file-no-setup.json -f ./community-solid-server",
15 | "start-wait": "npm run start-wait:frontend && npm run start-wait:css",
16 | "start-wait:frontend": "npm run start & wait-on http://localhost:3000",
17 | "start-wait:css": "npm run css & wait-on http://localhost:8080"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/otto-aa/solid-filemanager.git"
22 | },
23 | "keywords": [
24 | "filemanager",
25 | "solid"
26 | ],
27 | "author": "A_A",
28 | "bugs": {
29 | "url": "https://github.com/otto-aa/solid-filemanager/issues"
30 | },
31 | "homepage": "https://otto-aa.github.io/solid-filemanager/",
32 | "dependencies": {
33 | "@inrupt/solid-client-authn-browser": "^2.3.0",
34 | "@material-ui/core": "^4.12.4",
35 | "@material-ui/icons": "^4.11.3",
36 | "@types/classnames": "^2.2.10",
37 | "@types/history": "^4.7.5",
38 | "@types/jest": "^24.9.1",
39 | "@types/jszip": "^3.1.7",
40 | "@types/material-ui": "^0.21.12",
41 | "@types/mime": "^2.0.1",
42 | "@types/node": "^11.15.12",
43 | "@types/rdflib": "^0.17.1",
44 | "@types/react": "^16.9.34",
45 | "@types/react-dom": "^16.9.7",
46 | "@types/react-plyr": "^2.1.0",
47 | "@types/react-redux": "^7.1.7",
48 | "gh-pages": "^3.2.3",
49 | "history": "^4.10.1",
50 | "jszip": "^3.4.0",
51 | "mime": "^2.4.4",
52 | "plyr": "^3.5.10",
53 | "rdflib": "^2.2.19",
54 | "react": "^16.13.1",
55 | "react-dom": "^16.13.1",
56 | "react-plyr": "^2.2.0",
57 | "react-redux": "^6.0.1",
58 | "react-scripts": "^5.0.0",
59 | "redux": "^4.0.5",
60 | "redux-thunk": "~2.3.0",
61 | "solid-file-client": "^2.1.3",
62 | "typeface-roboto": "0.0.54",
63 | "typescript": "^3.8.3"
64 | },
65 | "eslintConfig": {
66 | "extends": "react-app"
67 | },
68 | "browserslist": [
69 | ">0.2%",
70 | "not dead",
71 | "not ie <= 11",
72 | "not op_mini all"
73 | ],
74 | "devDependencies": {
75 | "@inrupt/solid-client-authn-core": "^1.12.1",
76 | "@solid/community-server": "^4.0.0",
77 | "cypress": "^9.6.0",
78 | "uuid": "^8.3.2",
79 | "wait-on": "^6.0.1"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/Api/Item.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Class for an arbitrary item from a solid pod
3 | */
4 | export class Item {
5 | _name: string;
6 | _path: string[];
7 | _url: string;
8 | _size?: string
9 |
10 | constructor(url: string, size?: string) {
11 | const path = getPathFromUrl(url);
12 |
13 | this._name = path.pop() || '';
14 | this._path = path;
15 | this._url = url;
16 | this._size = size;
17 | }
18 |
19 | // Make properties readonly
20 | get name() { return this._name; }
21 | get path() { return this._path; }
22 | get url() { return this._url; }
23 | get size() { return this._size; }
24 |
25 | equals(item: Item) {
26 | return this.name === item.name
27 | && this.path.length === item.path.length
28 | && this.path.every((val, index) => val === item.path[index]);
29 | }
30 |
31 | getDisplayName() {
32 | return decodeURI(this.name);
33 | }
34 |
35 | getDisplaySize() {
36 | return this._size ? getHumanFileSize(this._size) : 'Unknown size';
37 | }
38 | }
39 |
40 | export class FileItem extends Item {
41 | isImage() {
42 | return patterns.image.test(this.name);
43 | }
44 |
45 | isMedia() {
46 | return patterns.media.test(this.name);
47 | }
48 |
49 | isEditable() {
50 | return patterns.editable.test(this.name);
51 | }
52 |
53 | isExtractable() {
54 | return patterns.extractable.test(this.name);
55 | }
56 |
57 | isVideo() {
58 | return patterns.video.test(this.name);
59 | }
60 | }
61 |
62 | export class FolderItem extends Item { }
63 |
64 |
65 | // regex patterns for testing if a file is of a specific type
66 | const patterns = {
67 | editable: /\.(txt|diff?|patch|svg|asc|cnf|cfg|conf|html?|cfm|cgi|aspx?|ini|pl|py|md|css|cs|jsx?|jsp|log|htaccess|htpasswd|gitignore|gitattributes|env|json|atom|eml|rss|markdown|sql|xml|xslt?|sh|rb|as|bat|cmd|cob|for|ftn|frm|frx|inc|lisp|scm|coffee|php[3-6]?|java|c|cbl|go|h|scala|vb|tmpl|lock|go|yml|yaml|tsv|lst|ttl)$/i,
68 | image: /\.(jpe?g|gif|bmp|png|svg|tiff?)$/i,
69 | media: /\.(mp3|ogg|wav|mp4|webm)$/i,
70 | video: /\.(mp4|webm|ogg)$/i,
71 | extractable: /\.(zip)$/i
72 | };
73 |
74 | /**
75 | * Calculate file size by bytes in human readable format
76 | */
77 | export const getHumanFileSize = (byteString: string|number): string => {
78 | const bytes = typeof byteString === 'string' ?
79 | parseInt(byteString)
80 | : byteString;
81 | const e = (Math.log(bytes) / Math.log(1e3)) | 0;
82 | return +(bytes / Math.pow(1e3, e)).toFixed(2) + ' ' + ('kMGTPEZY'[e - 1] || '') + 'B';
83 | };
84 |
85 |
86 | /**
87 | * Get path including the last element (e.g. [public, test, index.html])
88 | */
89 | const getPathFromUrl = (urlString: string): string[] => {
90 | const url = new URL(urlString);
91 | return url.pathname.split('/').filter(val => val !== '');
92 | }
--------------------------------------------------------------------------------
/src/Components/Dialogs/CreateFile/CreateFile.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, createRef } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 | import { connect } from 'react-redux';
9 | import { createFile, closeDialog, MyDispatch } from '../../../Actions/Actions';
10 | import { AppState } from '../../../Reducers/reducer';
11 | import { DialogStateProps, DialogDispatchProps, DialogButtonClickEvent } from '../dialogTypes';
12 | import { DIALOGS } from '../../../Actions/actionTypes';
13 |
14 | class FormDialog extends Component {
15 | private textField: React.RefObject = createRef();
16 |
17 | handleSubmit(event: DialogButtonClickEvent) {
18 | const textField = this.textField.current;
19 | if (textField) {
20 | const fileName = textField.value;
21 | this.props.handleSubmit(event, { fileName });
22 | }
23 | }
24 |
25 | render() {
26 | const { handleClose, open } = this.props;
27 |
28 | return (
29 |
30 |
44 |
45 | );
46 | }
47 | }
48 |
49 | interface DispatchProps extends DialogDispatchProps {
50 | handleSubmit(event: DialogButtonClickEvent, { fileName }: { fileName: string }): void;
51 | }
52 | interface CreateFileProps extends DialogStateProps, DispatchProps { }
53 |
54 | const mapStateToProps = (state: AppState): DialogStateProps => {
55 | return {
56 | open: state.visibleDialogs.CREATE_FILE
57 | };
58 | };
59 |
60 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
61 | return {
62 | handleClose: () => {
63 | dispatch(closeDialog(DIALOGS.CREATE_FILE));
64 | },
65 | handleSubmit: (event, { fileName }) => {
66 | event.preventDefault();
67 | dispatch(createFile(fileName));
68 | }
69 | };
70 | };
71 |
72 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
73 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/CreateFolder/CreateFolder.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, createRef } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 | import { connect } from 'react-redux';
9 | import { createNewFolder, closeDialog, MyDispatch } from '../../../Actions/Actions';
10 | import { DialogStateProps, DialogDispatchProps, DialogButtonClickEvent } from '../dialogTypes';
11 | import { AppState } from '../../../Reducers/reducer';
12 | import { DIALOGS } from '../../../Actions/actionTypes';
13 |
14 | class FormDialog extends Component {
15 | private textField: React.RefObject = createRef();
16 |
17 | handleSubmit(event: DialogButtonClickEvent) {
18 | const textField = this.textField.current;
19 | if (textField) {
20 | const folderName = textField.value;
21 | this.props.handleSubmit(event, { folderName });
22 | }
23 | }
24 |
25 | render() {
26 | const { handleClose, open } = this.props;
27 |
28 | return (
29 |
30 |
44 |
45 | );
46 | }
47 | }
48 |
49 | interface DispatchProps extends DialogDispatchProps {
50 | handleSubmit(event: DialogButtonClickEvent, { folderName }: { folderName: string }): void;
51 | }
52 | interface CreateFolderProps extends DialogStateProps, DispatchProps { }
53 |
54 | const mapStateToProps = (state: AppState): DialogStateProps => {
55 | return {
56 | open: state.visibleDialogs.CREATE_FOLDER
57 | };
58 | };
59 |
60 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
61 | return {
62 | handleClose: () => {
63 | dispatch(closeDialog(DIALOGS.CREATE_FOLDER));
64 | },
65 | handleSubmit: (event, { folderName }) => {
66 | event.preventDefault();
67 | dispatch(createNewFolder(folderName));
68 | }
69 | };
70 | };
71 |
72 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
73 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/Media/Media.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import DialogTitle from '@material-ui/core/DialogTitle';
7 | import { connect } from 'react-redux';
8 | import { MyDispatch, closeDialog } from '../../../Actions/Actions';
9 | import Plyr from 'react-plyr';
10 | import 'plyr/dist/plyr.css';
11 | import { FileItem } from '../../../Api/Item';
12 | import { DialogStateProps, DialogDispatchProps } from '../dialogTypes';
13 | import { AppState } from '../../../Reducers/reducer';
14 | import { DIALOGS } from '../../../Actions/actionTypes';
15 |
16 | class FormDialog extends Component {
17 | render() {
18 | const { file, handleClose, open } = this.props;
19 |
20 | const fileName = file ? file.name : undefined;
21 | const url = file ? file.url : undefined;
22 | // TODO: const provider = file ? (file.isVideo() ? 'html5' : 'audio') : '';
23 | const type = file ? (file.isVideo() ? 'video' : 'audio') : undefined;
24 |
25 | return (
26 |
27 | Display Media
28 |
29 | {
30 | file ?
31 | (
32 |
33 |
Playing {fileName}
34 |
35 |
36 | )
37 | : No media file opened
38 |
39 | }
40 |
41 |
42 |
43 | Close
44 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | interface StateProps extends DialogStateProps {
52 | file?: FileItem;
53 | }
54 | interface MediaProps extends StateProps, DialogDispatchProps {}
55 |
56 |
57 | const mapStateToProps = (state: AppState): StateProps => {
58 | const open = state.visibleDialogs.MEDIA;
59 |
60 | const file = state.items.selected[0];
61 |
62 | if (file instanceof FileItem) {
63 | return {
64 | open,
65 | file,
66 | };
67 | }
68 | return { open };
69 | };
70 |
71 | const mapDispatchToProps = (dispatch: MyDispatch): DialogDispatchProps => {
72 | return {
73 | handleClose: () => {
74 | dispatch(closeDialog(DIALOGS.MEDIA));
75 | }
76 | };
77 | };
78 |
79 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
80 |
--------------------------------------------------------------------------------
/cypress/integration/file_spec.js:
--------------------------------------------------------------------------------
1 | describe('file operations', () => {
2 | beforeEach('login user', () => {
3 | cy.createRandomAccount().as('user')
4 | })
5 |
6 | it('can create text file', function () {
7 | cy.login(this.user)
8 | cy.get('[aria-label=More]').click()
9 | cy.contains('Create file').click()
10 | cy.focused().type('pandas.txt{enter}')
11 |
12 | // enter file content
13 | // workaround because textarea somehow gets rendered multiple times
14 | cy.wait(5000)
15 | cy.get('textarea').type('Pandas are cool')
16 | cy.contains('Update').click()
17 | cy.contains('Update').should('not.exist')
18 |
19 | // displays created file
20 | cy.contains('pandas.txt')
21 | })
22 |
23 | it('can read a text file', function () {
24 | const fileName = 'file.txt'
25 | const fileUrl = `${this.user.podUrl}/${fileName}`
26 | const fileContent = 'some cool content'
27 | cy.givenTextFile(this.user, fileUrl, fileContent)
28 | cy.login(this.user)
29 |
30 | cy.contains(fileName).dblclick()
31 | cy.contains(fileContent)
32 | })
33 |
34 | it('can copy a text file to a different folder', function () {
35 | const fileName = 'file.txt'
36 | const fileUrl = `${this.user.podUrl}/${fileName}`
37 | const fileContent = 'some cool content'
38 | const folderName = 'some folder'
39 | const folderUrl = `${this.user.podUrl}/${folderName}`
40 | cy.givenTextFile(this.user, fileUrl, fileContent)
41 | cy.givenFolder(this.user, folderUrl)
42 | cy.login(this.user)
43 |
44 | cy.intercept({
45 | method: 'PUT',
46 | url: `${this.user.podUrl}/${encodeURIComponent(folderName)}/${fileName}`
47 | }).as('putNewFile')
48 | cy.intercept({
49 | method: 'GET',
50 | url: `${this.user.podUrl}/${encodeURIComponent(folderName)}`
51 | }).as('getTargetFolder')
52 |
53 | // open copy menu
54 | cy.contains(fileName).rightclick()
55 | cy.contains('Copy').click()
56 |
57 | // select target folder and copy
58 | // TODO: fix %20 in application
59 | cy.contains('some%20folder').click()
60 | cy.wait('@getTargetFolder').its('response.body').should('not.include', fileName).then(cy.log)
61 | cy.get('button').contains('Copy').click()
62 | cy.wait('@putNewFile').then(({ request, response }) => {
63 | expect(request.body).to.equal(fileContent)
64 | expect(request.headers).to.include({
65 | 'content-type': 'text/plain'
66 | })
67 | expect(response).to.have.property('statusCode', 201)
68 | })
69 |
70 | // open target folder in file explorer
71 | cy.contains(folderName).dblclick()
72 | cy.wait('@getTargetFolder').its('response.body').should('include', fileName).then(cy.log)
73 | cy.location('search').should('include', encodeURIComponent(encodeURIComponent(folderName)))
74 |
75 | // open copied file
76 | // TODO: fix double url encoding
77 | cy.contains(fileName).dblclick()
78 | cy.contains(fileContent)
79 | })
80 | })
81 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/Rename/Rename.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, createRef } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 | import { connect } from 'react-redux';
9 | import { renameFile, renameFolder, MyDispatch, closeDialog } from '../../../Actions/Actions';
10 | import { FolderItem, Item } from '../../../Api/Item';
11 | import { DialogStateProps, DialogDispatchProps, DialogButtonClickEvent } from '../dialogTypes';
12 | import { AppState } from '../../../Reducers/reducer';
13 | import { DIALOGS } from '../../../Actions/actionTypes';
14 | class FormDialog extends Component {
15 | private textField: React.RefObject = createRef();
16 |
17 | handleSubmit(event: DialogButtonClickEvent) {
18 | const textField = this.textField.current;
19 | const item = this.props.item;
20 | if (textField && item) {
21 | const newName = textField.value;
22 | this.props.handleSubmit(event, { item, newName });
23 | }
24 | }
25 |
26 | render() {
27 | const { handleClose, open, item } = this.props;
28 | const previousName = item ? item.name : '';
29 |
30 | return (
31 |
32 |
46 |
47 | );
48 | }
49 | }
50 |
51 | interface StateProps extends DialogStateProps {
52 | item?: Item;
53 | }
54 | interface DispatchProps extends DialogDispatchProps {
55 | handleSubmit(event: DialogButtonClickEvent, { item, newName }: { item: Item, newName: string }): void;
56 | }
57 | interface RenameProps extends StateProps, DispatchProps {}
58 |
59 |
60 | const mapStateToProps = (state: AppState): StateProps => {
61 | return {
62 | open: state.visibleDialogs.RENAME,
63 | item: state.items.selected[0],
64 | };
65 | };
66 |
67 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
68 | return {
69 | handleClose: () => {
70 | dispatch(closeDialog(DIALOGS.RENAME));
71 | },
72 | handleSubmit: (event, { item, newName }) => {
73 | event.preventDefault();
74 | if (item instanceof FolderItem) // TODO: Create renameItem
75 | dispatch(renameFolder(item.name, newName));
76 | else
77 | dispatch(renameFile(item.name, newName));
78 | }
79 | };
80 | };
81 |
82 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
83 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/UploadFile/UploadFile.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import DialogTitle from '@material-ui/core/DialogTitle';
7 | import LinearProgress from '@material-ui/core/LinearProgress';
8 | import { connect } from 'react-redux';
9 | import { resetFileUploader, uploadFiles, setFileUploadList, MyDispatch, resetFileUploadList } from '../../../Actions/Actions';
10 | import FileUploader from '../../FileUploader/FileUploader';
11 | import { DialogStateProps, DialogDispatchProps } from '../dialogTypes';
12 | import { AppState } from '../../../Reducers/reducer';
13 |
14 | class FormDialog extends Component {
15 |
16 | render() {
17 | const { handleClose, handleReset, handleSubmit, open, canUpload, progress, fileList, handleSelectedFiles } = this.props;
18 |
19 | return (
20 |
21 |
38 |
39 | );
40 | }
41 | }
42 |
43 | interface StateProps extends DialogStateProps {
44 | canUpload: boolean;
45 | fileList: FileList|null;
46 | progress: number;
47 | }
48 | interface DispatchProps extends DialogDispatchProps {
49 | handleSelectedFiles(event: React.ChangeEvent): void;
50 | handleReset(): void;
51 | }
52 | interface UploadFileProps extends StateProps, DispatchProps {}
53 |
54 |
55 | const mapStateToProps = (state: AppState): StateProps => {
56 | return {
57 | open: state.visibleDialogs.UPLOAD_FILE,
58 | canUpload: state.upload.fileList ? state.upload.fileList.length > 0 : false,
59 | fileList: state.upload.fileList,
60 | progress: state.upload.progress,
61 | };
62 | };
63 |
64 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
65 | return {
66 | handleClose: (event) => {
67 | dispatch(resetFileUploader());
68 | },
69 | handleSubmit: (event) => {
70 | event.preventDefault();
71 | dispatch(uploadFiles());
72 | },
73 | handleSelectedFiles: (event) => {
74 | const files = event.target.files;
75 | dispatch(setFileUploadList(files));
76 | },
77 | handleReset: () => {
78 | dispatch(resetFileUploadList());
79 | }
80 | };
81 | };
82 |
83 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
84 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/Settings/Settings.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import DialogTitle from '@material-ui/core/DialogTitle';
7 | import { connect } from 'react-redux';
8 | import { MyDispatch, closeDialog, toggleWithAcl, toggleWithMeta } from '../../../Actions/Actions';
9 | import { DialogDispatchProps, DialogStateProps } from '../dialogTypes';
10 | import { AppState } from '../../../Reducers/reducer';
11 | import { DIALOGS } from '../../../Actions/actionTypes';
12 | import { FormControlLabel, FormGroup, Switch } from '@material-ui/core';
13 |
14 | class FormDialog extends Component {
15 | state = {
16 | withAcl: false,
17 | withMeta: false,
18 | };
19 |
20 | componentWillReceiveProps(props: SettingsProps) {
21 | const { withAcl, withMeta } = props;
22 | this.setState({ withAcl, withMeta })
23 | }
24 |
25 | handleChange(event: React.ChangeEvent) {
26 | this.setState({ location: event.target.value })
27 | }
28 |
29 | handleToggleWithAcl() {
30 | this.props.handleToggleWithAcl();
31 | }
32 |
33 | handleToggleWithMeta() {
34 | this.props.handleToggleWithMeta();
35 | }
36 |
37 | render() {
38 | let { withAcl, withMeta } = this.state;
39 | const { handleClose, open } = this.props;
40 |
41 | return (
42 |
43 |
57 |
58 | );
59 | }
60 | }
61 |
62 | interface StateProps extends DialogStateProps {
63 | withAcl: boolean;
64 | withMeta: boolean;
65 | }
66 | interface DispatchProps extends DialogDispatchProps {
67 | handleToggleWithAcl(): void;
68 | handleToggleWithMeta(): void;
69 | }
70 | interface SettingsProps extends StateProps, DispatchProps { }
71 |
72 |
73 | const mapStateToProps = (state: AppState): StateProps => {
74 | return {
75 | open: state.visibleDialogs.SETTINGS,
76 | withAcl: state.settings.withAcl,
77 | withMeta: state.settings.withMeta,
78 | };
79 | };
80 |
81 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
82 | return {
83 | handleClose: () => dispatch(closeDialog(DIALOGS.SETTINGS)),
84 | handleToggleWithAcl: () => dispatch(toggleWithAcl()),
85 | handleToggleWithMeta: () => dispatch(toggleWithMeta()),
86 | };
87 | };
88 |
89 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
90 |
--------------------------------------------------------------------------------
/src/Components/Notification/NotificationBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 | import CheckCircleIcon from '@material-ui/icons/CheckCircle';
4 | import ErrorIcon from '@material-ui/icons/Error';
5 | import InfoIcon from '@material-ui/icons/Info';
6 | import green from '@material-ui/core/colors/green';
7 | import amber from '@material-ui/core/colors/amber';
8 | import SnackbarContent from '@material-ui/core/SnackbarContent';
9 | import WarningIcon from '@material-ui/icons/Warning';
10 | import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles';
11 | import { connect } from 'react-redux';
12 | import { AppState } from '../../Reducers/reducer';
13 |
14 |
15 | enum VariantTypes {
16 | SUCCESS = 'success',
17 | WARNING = 'warning',
18 | ERROR = 'error',
19 | INFO = 'info'
20 | }
21 |
22 | const variantIcon = {
23 | [VariantTypes.SUCCESS]: CheckCircleIcon,
24 | [VariantTypes.WARNING]: WarningIcon,
25 | [VariantTypes.ERROR]: ErrorIcon,
26 | [VariantTypes.INFO]:InfoIcon,
27 | };
28 |
29 | const styles1 = (theme: Theme) => createStyles({
30 | [VariantTypes.SUCCESS]: {
31 | backgroundColor: green[600],
32 | },
33 | [VariantTypes.ERROR]: {
34 | backgroundColor: theme.palette.error.dark,
35 | },
36 | [VariantTypes.INFO]: {
37 | backgroundColor: theme.palette.primary.dark,
38 | },
39 | [VariantTypes.WARNING]: {
40 | backgroundColor: amber[700],
41 | },
42 | icon: {
43 | fontSize: 20,
44 | },
45 | iconVariant: {
46 | opacity: 0.9,
47 | marginRight: theme.spacing(),
48 | },
49 | message: {
50 | display: 'flex',
51 | alignItems: 'center',
52 | },
53 | });
54 |
55 | function MySnackbarContent(props: MySnackbarContentProps) {
56 | const { open, classes, className, message, variant, ...other } = props;
57 | const Icon = variantIcon[variant];
58 |
59 | return (
60 | open ?
61 |
66 |
67 | {message}
68 |
69 | }
70 | {...other}
71 | />
72 | : <>>
73 | );
74 | }
75 |
76 | interface MySnackbarContentProps extends WithStyles {
77 | open: boolean;
78 | className: string;
79 | variant: VariantTypes;
80 | message: string;
81 | [k: string]: any;
82 | }
83 |
84 | const MySnackbarContentWrapper = withStyles(styles1)(MySnackbarContent);
85 |
86 | const styles2 = (theme: Theme) => createStyles({
87 | margin: {
88 | margin: theme.spacing(),
89 | },
90 | });
91 |
92 | function CustomizedSnackbars(props: CustomizedSnackbarsProps) {
93 | const { classes, open, errorMsg } = props;
94 | return (
95 |
101 | );
102 | }
103 |
104 | interface StateProps {
105 | open: boolean;
106 | errorMsg: string;
107 | }
108 | interface CustomizedSnackbarsProps extends StateProps, WithStyles {}
109 |
110 | const mapStateToProps = (state: AppState): StateProps => {
111 | return {
112 | open: !!state.errorMessage,
113 | errorMsg: state.errorMessage,
114 | };
115 | };
116 |
117 | const mapDispatchToProps = () => ({});
118 |
119 | export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles2)(CustomizedSnackbars));
120 |
121 |
122 |
--------------------------------------------------------------------------------
/src/Components/ContextMenu/ContextMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import './ContextMenu.css';
4 | import Menu from '@material-ui/core/Menu';
5 | import OpenAction from './ContextMenuActions/OpenAction';
6 | import RemoveAction from './ContextMenuActions/RemoveAction';
7 | import MoveAction from './ContextMenuActions/MoveAction';
8 | import CopyAction from './ContextMenuActions/CopyAction';
9 | import EditAction from './ContextMenuActions/EditAction';
10 | import RenameAction from './ContextMenuActions/RenameAction';
11 | import ZipAction from './ContextMenuActions/ZipAction';
12 | import ExtractAction from './ContextMenuActions/ExtractAction';
13 | import DownloadAction from './ContextMenuActions/DownloadAction';
14 | import OpenInNewTabAction from './ContextMenuActions/OpenInNewTabAction';
15 | import { Item, FileItem, FolderItem } from '../../Api/Item';
16 | import { AppState } from '../../Reducers/reducer';
17 |
18 | class ContextMenu extends Component {
19 |
20 | render() {
21 | const { acts, open, x, y } = this.props;
22 |
23 | return (
24 |
25 |
{ }}
34 | PaperProps={{ style: { width: 190 } }}>
35 | {acts.includes('open') && }
36 | {acts.includes('openInNewTab') && }
37 | {acts.includes('download') && }
38 | {acts.includes('compress') && }
39 | {acts.includes('extract') && }
40 | {acts.includes('edit') && }
41 | {acts.includes('copy') && }
42 | {acts.includes('move') && }
43 | {acts.includes('rename') && }
44 | {acts.includes('remove') && }
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | interface StateProps {
52 | acts: string[];
53 | open: boolean;
54 | x: number;
55 | y: number;
56 | }
57 | interface ContextMenuProps extends StateProps {}
58 |
59 |
60 | const mapStateToProps = (state: AppState): StateProps => {
61 | return {
62 | x: state.contextMenu.x,
63 | y: state.contextMenu.y,
64 | open: state.contextMenu.open,
65 | acts: getActionsForMultipleItems(state.items.selected),
66 | };
67 | };
68 |
69 | const mapDispatchToProps = () => ({});
70 |
71 |
72 | /**
73 | * Get available actions for multiple items
74 | */
75 | const getActionsForMultipleItems = (items: Item[]): string[] => {
76 | return items.length === 1 ?
77 | getActionsForItem(items[0])
78 | : [
79 | 'copy',
80 | 'move',
81 | 'remove',
82 | 'download',
83 | 'compress',
84 | ];
85 | };
86 |
87 | /**
88 | * Get available actions for an item
89 | */
90 | const getActionsForItem = (item: Item) => {
91 | const commonActions = [
92 | 'openInNewTab',
93 | 'copy',
94 | 'move',
95 | 'rename',
96 | 'remove',
97 | 'download',
98 | ];
99 | return [
100 | ...commonActions,
101 | ...((item instanceof FileItem) ?
102 | getActionsForFile(item)
103 | : getActionsForFolder(item))
104 | ];
105 | };
106 |
107 | /**
108 | * Get available file specific actions
109 | */
110 | const getActionsForFile = (file: FileItem) => {
111 | const actions = [];
112 | file.isEditable() && actions.push('edit');
113 | file.isExtractable() && actions.push('extract');
114 | (file.isImage() || file.isMedia()) && actions.push('open');
115 |
116 | return actions;
117 | };
118 |
119 | /**
120 | * Get available folder specific actions
121 | */
122 | const getActionsForFolder = (folder: FolderItem) => {
123 | return [
124 | 'open',
125 | 'compress'
126 | ];
127 | };
128 |
129 |
130 | export default connect(mapStateToProps, mapDispatchToProps)(ContextMenu);
--------------------------------------------------------------------------------
/src/Components/Dialogs/ChooseLocation/ChooseLocation.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import DialogTitle from '@material-ui/core/DialogTitle';
7 | import FileListSublist from '../../FileList/FileListSublist/FileListSublist';
8 | import KeyboardArrowLeftIcon from '@material-ui/icons/KeyboardArrowLeft';
9 | import { Item, FolderItem } from '../../../Api/Item';
10 | import * as ApiHandler from '../../../Api/ApiHandler';
11 |
12 | class FormDialog extends Component {
13 | private host: string;
14 | private path: string[];
15 |
16 | constructor(props: OwnProps) {
17 | super(props);
18 | const { initialPath, initialHost } = props;
19 | this.host = initialHost;
20 | this.path = initialPath;
21 |
22 | this.state = {
23 | items: [],
24 | isLoading: true,
25 | wasPreviouslyOpen: false,
26 | };
27 | }
28 |
29 | componentDidUpdate(prevProps: OwnProps) {
30 | if (prevProps.initialHost !== this.props.initialHost
31 | || prevProps.initialPath.join('') !== this.props.initialPath.join('')) {
32 | this.host = this.props.initialHost;
33 | this.path = this.props.initialPath;
34 | }
35 | if (this.props.open && !this.state.wasPreviouslyOpen) {
36 | this.setState({ wasPreviouslyOpen: true })
37 | this.updateItems()
38 | }
39 | if (!this.props.open && this.state.wasPreviouslyOpen) {
40 | this.setState({ wasPreviouslyOpen: false })
41 | }
42 | }
43 |
44 | handleGoBack() {
45 | this.path = this.path.slice(0, -1);
46 | this.updateItems();
47 | }
48 |
49 | handleOpenFolder(folder: FolderItem) {
50 | this.path = [...folder.path, folder.name];
51 | this.updateItems();
52 | }
53 |
54 | async updateItems() {
55 | this.setState({ isLoading: true });
56 | const items = (await ApiHandler.getItemList(this.path.join('/')))
57 | .filter(item => item instanceof FolderItem);
58 |
59 | this.setState({ isLoading: false, items });
60 | }
61 |
62 | render() {
63 | const { open, handleClose, handleSubmit, actionName } = this.props;
64 | const { items, isLoading } = this.state;
65 | const host = this.host;
66 | const path = this.path;
67 | const url = `${host}/${path.join('/')}`;
68 | const canGoBack = path.length > 0;
69 |
70 | return (
71 |
72 |
92 |
93 | );
94 | }
95 | }
96 |
97 | interface OwnProps {
98 | open: boolean;
99 | actionName: string;
100 | initialHost: string;
101 | initialPath: string[];
102 | handleSubmit({ host, path }: { host: string, path: string[] }): void;
103 | handleClose(): void;
104 | }
105 |
106 | interface OwnState {
107 | items: Item[];
108 | isLoading: boolean;
109 | wasPreviouslyOpen: boolean;
110 | }
111 |
112 | export default FormDialog;
113 |
--------------------------------------------------------------------------------
/src/Components/HistoryHandler/HistoryHandler.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { connect } from "react-redux";
3 | import { createBrowserHistory, History, Location } from "history";
4 | import { MyDispatch, setHost, enterFolder } from "../../Actions/Actions";
5 | import { AppState } from "../../Reducers/reducer";
6 |
7 | class HistoryHandler extends Component {
8 | private history: History;
9 | private states: LocationState[];
10 | private stateIndex: number;
11 |
12 | constructor(props: HistoryHandlerProps) {
13 | super(props);
14 |
15 | this.states = [];
16 | this.stateIndex = -1;
17 | this.history = createBrowserHistory();
18 | this.history.listen((location, action) => {
19 | switch(action) {
20 | case 'POP':
21 | this.handlePop(location);
22 | break;
23 | case 'REPLACE':
24 | this.handleReplace(location);
25 | break;
26 | case 'PUSH':
27 | this.handlePush(location);
28 | break;
29 | }
30 | });
31 | }
32 |
33 | componentDidUpdate() {
34 | const { host, path } = this.props;
35 |
36 | // Don't update history when the host is invalid
37 | if (host === null)
38 | return;
39 | if (this.states.length === 0 || this.stateIndex < 0)
40 | return this.updateBrowserHistory();
41 |
42 | const prevState = this.states[this.stateIndex];
43 |
44 | if (!locationsEqual({ host, path }, prevState))
45 | this.updateBrowserHistory();
46 | }
47 |
48 | updateBrowserHistory() {
49 | const { host, path } = this.props;
50 | const url = encodeURI(`${host}/${path.join('/')}`);
51 | const newState = {
52 | host: host || '',
53 | path,
54 | index: this.stateIndex + 1,
55 | };
56 |
57 | this.history.push(`?url=${url}`, newState);
58 | }
59 |
60 | handlePop(location: Location) {
61 | this.stateIndex = location.state.index;
62 | this.props.handlePop(location);
63 | }
64 |
65 | handleReplace(location: Location) {
66 | this.states[this.stateIndex] = location.state;
67 | }
68 |
69 | handlePush(location: Location) {
70 | this.states = [...this.states.slice(0, ++this.stateIndex), location.state];
71 | }
72 |
73 | render() {
74 | // This Component doesn't provide anything to the DOM
75 | // The only reason it is a component is to get access to the state and dispatch
76 | return <>>;
77 | }
78 | }
79 |
80 | interface LocationState extends MyLocation {
81 | index: number;
82 | }
83 | interface MyLocation {
84 | host: string;
85 | path: string[];
86 | }
87 |
88 |
89 | interface StateProps {
90 | host: string | null;
91 | path: string[];
92 | }
93 | interface DispatchProps {
94 | handlePop(location: Location): void;
95 | }
96 | interface HistoryHandlerProps extends StateProps, DispatchProps { }
97 |
98 |
99 | const mapStateToProps = (state: AppState): StateProps => ({
100 | host: state.account.host,
101 | path: state.path
102 | });
103 |
104 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
105 | return {
106 | handlePop: (location: Location) => {
107 | let host = '';
108 | let path: string[] = [];
109 |
110 | if (location && typeof location.state !== typeof undefined) {
111 | ({ host, path } = location.state);
112 | }
113 | else {
114 | const params = new URLSearchParams(location.search.substr(1));
115 | const url = params.get('url');
116 | if (url !== null) {
117 | ({ host, path } = getLocationObjectFromUrl(url));
118 | }
119 | }
120 | dispatch(setHost(host));
121 | dispatch(enterFolder(path));
122 | }
123 | };
124 | };
125 |
126 | export const getLocationObjectFromUrl = (urlString: string) => {
127 | const url = new URL(urlString);
128 | const host = url.origin;
129 | const path = url.pathname.split('/').filter(val => val !== '');
130 |
131 | return {
132 | host,
133 | path
134 | };
135 | }
136 |
137 | const locationsEqual = (first: MyLocation, second: MyLocation) => {
138 | return first.host === second.host
139 | && first.path.length === second.path.length
140 | && first.path.every((val, index) => val === second.path[index]);
141 | }
142 |
143 | export default connect(mapStateToProps, mapDispatchToProps)(HistoryHandler);
144 |
--------------------------------------------------------------------------------
/src/Components/File/File.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import {
4 | setSelectedItemsFromLastTo, loadAndEditFile, loadAndDisplayFile, displaySelectedMediaFile,
5 | rightClickOnFile, enterFolderByItem, MyDispatch, openContextMenu, toggleSelectedItem, selectItems
6 | } from '../../Actions/Actions';
7 | import './File.css';
8 |
9 | import ListItem from '@material-ui/core/ListItem';
10 | import ListItemAvatar from '@material-ui/core/ListItemAvatar';
11 | import ListItemText from '@material-ui/core/ListItemText';
12 | import Avatar from '@material-ui/core/Avatar';
13 | import FolderIcon from '@material-ui/icons/Folder';
14 | import FileIcon from '@material-ui/icons/InsertDriveFile';
15 | import blue from '@material-ui/core/colors/blue';
16 | import { FileItem, Item } from '../../Api/Item';
17 | import { AppState } from '../../Reducers/reducer';
18 |
19 | class File extends Component {
20 | render() {
21 | const { isSelected, item, handleClick, handleDoubleClick, handleContextMenu } = this.props;
22 | const avatarStyle = {
23 | backgroundColor: isSelected ? blue['A200'] : undefined
24 | };
25 | const realSize = (item instanceof FileItem) ? item.getDisplaySize() : null;
26 | return (
27 |
28 |
29 |
30 |
31 | { (item instanceof FileItem) ? : }
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 | }
40 |
41 |
42 | interface FileOwnProps {
43 | item: Item;
44 | }
45 | interface StateProps {
46 | isSelected: boolean;
47 | }
48 | interface DispatchProps {
49 | handleClick(event: React.MouseEvent): void;
50 | handleDoubleClick(): void;
51 | handleContextMenu(event: React.MouseEvent): void;
52 | }
53 | interface FileProps extends FileOwnProps, StateProps, DispatchProps {}
54 |
55 |
56 | const mapStateToProps = (state: AppState, ownProps: FileOwnProps): StateProps => {
57 | return {
58 | isSelected: state.items.selected.includes(ownProps.item)
59 | };
60 | };
61 |
62 |
63 | const mapDispatchToProps = (dispatch: MyDispatch, ownProps: FileOwnProps): DispatchProps => {
64 | return {
65 | handleDoubleClick: () => {
66 | const item = ownProps.item;
67 |
68 | if (item instanceof FileItem) {
69 | if (item.isEditable())
70 | dispatch(loadAndEditFile(item.name));
71 | else if (item.isImage())
72 | dispatch(loadAndDisplayFile(item.name));
73 | else if (item.isMedia())
74 | dispatch(displaySelectedMediaFile());
75 | }
76 | else
77 | dispatch(enterFolderByItem(item));
78 | },
79 |
80 | handleContextMenu: (event: React.MouseEvent | React.TouchEvent) => {
81 | event.preventDefault();
82 | event.stopPropagation();
83 |
84 | let x = 0;
85 | let y = 0;
86 |
87 | if (event.nativeEvent instanceof MouseEvent) {
88 | x = event.nativeEvent.clientX;
89 | y = event.nativeEvent.clientY;
90 | }
91 | else if (event.nativeEvent instanceof TouchEvent) {
92 | x = event.nativeEvent.touches[0].pageX;
93 | y = event.nativeEvent.touches[0].pageY;
94 | }
95 | else {
96 | console.warn("Unknown click event", event);
97 | }
98 |
99 | if (event.shiftKey) {
100 | dispatch(setSelectedItemsFromLastTo(ownProps.item));
101 | } else {
102 | dispatch(rightClickOnFile(ownProps.item));
103 | }
104 |
105 | dispatch(openContextMenu({ x, y }));
106 | },
107 |
108 | handleClick: (event: React.MouseEvent | React.TouchEvent) => {
109 | event.stopPropagation();
110 |
111 | if (event.ctrlKey) {
112 | dispatch(toggleSelectedItem(ownProps.item));
113 | } else if (event.shiftKey) {
114 | dispatch(setSelectedItemsFromLastTo(ownProps.item));
115 | } else {
116 | dispatch(selectItems([ownProps.item]));
117 | }
118 | }
119 | };
120 | };
121 |
122 | export default connect(mapStateToProps, mapDispatchToProps)(File);
123 |
--------------------------------------------------------------------------------
/src/Components/Dialogs/Edit/Edit.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, createRef } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import DialogContentText from '@material-ui/core/DialogContentText';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 | import { connect } from 'react-redux';
9 | import { updateTextFile, MyDispatch, closeDialog } from '../../../Actions/Actions';
10 | import { DialogStateProps, DialogDispatchProps, DialogButtonClickEvent } from '../dialogTypes';
11 | import { AppState } from '../../../Reducers/reducer';
12 | import { DIALOGS } from '../../../Actions/actionTypes';
13 | import { Item } from '../../../Api/Item';
14 |
15 | class FormDialog extends Component {
16 | private textField: React.RefObject = createRef();
17 | state = {
18 | lastBlobUrl: null as string|null,
19 | content: null as string|null,
20 | contentType: null as string|null,
21 | loading: false
22 | };
23 |
24 | componentDidUpdate() {
25 | if (this.props.blobUrl !== this.state.lastBlobUrl) {
26 | this.setState({
27 | lastBlobUrl: this.props.blobUrl
28 | });
29 | this.setState({
30 | loading: true
31 | });
32 |
33 | this.props.blobUrl && fetch(this.props.blobUrl).then(async r => {
34 | this.setState({
35 | content: await r.text(),
36 | contentType: r.headers.get('content-type')
37 | });
38 | this.setState({
39 | loading: false
40 | });
41 | });
42 | }
43 | }
44 |
45 | handleSave(event: DialogButtonClickEvent) {
46 | event.preventDefault();
47 | const textField = this.textField.current;
48 | const item = this.props.item;
49 | if (textField && item) {
50 | const content = textField.value;
51 | const contentType = this.state.contentType ? this.state.contentType : 'text/plain';
52 | this.props.handleSubmit(event, {
53 | itemName: item.name,
54 | content,
55 | contentType
56 | });
57 | }
58 | }
59 |
60 | render() {
61 | const { handleClose, open, item } = this.props;
62 | const itemName = item ? item.getDisplayName() : 'No item selected';
63 | const textAreaStyle = {
64 | width: '100%',
65 | minHeight: '300px'
66 | };
67 | const textArea = ;
68 |
69 | return (
70 |
71 |
72 |
88 |
89 |
90 | );
91 | }
92 | }
93 |
94 | interface StateProps extends DialogStateProps {
95 | item: Item;
96 | blobUrl: string;
97 | }
98 | interface DispatchProps extends DialogDispatchProps {
99 | handleSubmit(event: DialogButtonClickEvent, { itemName, content, contentType }: { itemName: string, content: string, contentType: string }): void;
100 | }
101 | interface EditProps extends StateProps, DispatchProps {}
102 |
103 | const mapStateToProps = (state: AppState): StateProps => {
104 | return {
105 | open: state.visibleDialogs.EDIT, // TODO: rename visibleDialogs (e.g. to dialogIsOpen)
106 | item: state.items.selected[0],
107 | blobUrl: state.blob || ''
108 | };
109 | };
110 |
111 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
112 | return {
113 | handleClose: () => {
114 | dispatch(closeDialog(DIALOGS.EDIT));
115 | },
116 | handleSubmit: (event, { itemName, content, contentType }) => {
117 | dispatch(updateTextFile(itemName, content, contentType));
118 | }
119 | };
120 | };
121 |
122 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
123 |
--------------------------------------------------------------------------------
/src/types/solid-file-client.d.ts:
--------------------------------------------------------------------------------
1 | declare module "solid-file-client" {
2 | export = SFC.SolidFileClient
3 | }
4 |
5 | namespace SFC {
6 | /*~ Write your module's methods and properties in this class */
7 | declare class SolidFileClient {
8 | readonly static public LINKS = SFC.LINKS;
9 | readonly static public MERGE = SFC.MERGE;
10 | readonly static public AGENT = SFC.AGENT;
11 |
12 | constructor(auth: SolidAuthClient, options?: SolidFileClientOptions);
13 |
14 | readFile(url: string, request: RequestInit): Promise;
15 | readHead(url: string, request: RequestInit): Promise;
16 | deleteFile(url: string): Promise;
17 | deleteFolder(url: string): Promise;
18 |
19 | fetch(url: string, options?: RequestInit): Promise;
20 | get(url: string, options?: RequestInit): Promise;
21 | delete(url: string, options?: RequestInit): Promise;
22 | post(url: string, options?: RequestInit): Promise;
23 | put(url: string, options?: RequestInit): Promise;
24 | patch(url: string, options?: RequestInit): Promise;
25 | head(url: string, options?: RequestInit): Promise;
26 | options(url: string, options?: RequestInit): Promise;
27 |
28 | itemExists(url: string): Promise;
29 | postItem(url: string, content: FileContent, contentType: string, link: string, options?: WriteOptions): Promise;
30 | postFile(url: string, content: FileContent, contentType: string, options?: WriteOptions): Promise;
31 | putFile(url: string, content: FileContent, contentType: string, options?: WriteOptions): Promise;
32 | createFolder(url: string, options?: WriteOptions): Promise;
33 |
34 | readFolder(url: string, options?: ReadFolderOptions): Promise;
35 | getItemLinks(url: string, options?: ReadFolderOptions): Promise;
36 |
37 | copyFile(from: string, to: string, options?: WriteOptions): Promise;
38 | copyFolder(from: string, to: string, options?: WriteOptions): Promise;
39 | copy(from: string, to: string, options?: WriteOptions): Promise;
40 | move(from: string, to: string, options?: WriteOptions): Promise;
41 | rename(url: string, newName: string, options?: WriteOptions): Promise;
42 |
43 | copyMetaFileForItem(oldTargetFile: string, newTargetFile: string, options?: WriteOptions): Promise;
44 | copyAclFileForItem(oldTargetFile: string, newTargetFile: string, options?: WriteOptions): Promise;
45 | copyLinksForItem(oldTargetFile: string, newTargetFile: string, options?: WriteOptions): Promise;
46 |
47 | deleteFolderContents(url: string): Promise;
48 | deleteFolderRecursively(url: string): Promise;
49 |
50 | getAsZip(url: string, options?: ZipOptions): Promise;
51 | uploadExtractedZipArchive(zip : JSZip, destination: string, curFolder?: string, responses = [], options?: UnzipOptions): Promise;
52 | extractZipArchive(file: string, destination:string, options?: UnzipOptions): Promise<{ err: unknown[], info: unknown[] }>;
53 | }
54 |
55 | interface SolidFileClientOptions {
56 | enableLogging?: boolean | string;
57 | }
58 |
59 | interface ZipOptions {
60 | links?: LINKS;
61 | withAcl?: boolean;
62 | withMeta?: boolean;
63 | }
64 |
65 | interface UnzipOptions {
66 | links?: LINKS;
67 | withAcl?: boolean;
68 | withMeta?: boolean;
69 | merge?: 'replace', // or 'keep_target'
70 | createPath?: true,
71 | aclMode?: 'Control',
72 | aclAuth?: 'may',
73 | aclDefault?: 'must' // needed to allow acces to folder content .meta ...
74 | }
75 |
76 | type FileContent = Blob | string;
77 |
78 | enum MERGE {
79 | REPLACE = 'replace',
80 | KEEP_SOURCE = 'keep_source',
81 | KEEP_TARGET = 'keep_target'
82 | }
83 |
84 | enum LINKS {
85 | EXCLUDE = 'exlude',
86 | INCLUDE = 'include',
87 | INCLUDE_POSSIBLE = 'include_possible'
88 | }
89 |
90 | enum AGENT {
91 | NO_MODIFY = 'no_modify',
92 | TO_TARGET = 'to_target',
93 | TO_SOURCE = 'to_source'
94 | }
95 |
96 | interface WriteOptions {
97 | withAcl?: boolean;
98 | withMeta?: boolean;
99 | createPath?: boolean;
100 | agent?: AGENT;
101 | merge?: MERGE;
102 | }
103 |
104 | interface ReadFolderOptions {
105 | links?: LINKS;
106 | }
107 |
108 | interface SolidApiOptions {
109 | enableLogging?: string | boolean;
110 | }
111 |
112 | interface Links {
113 | acl?: string;
114 | meta?: string;
115 | }
116 |
117 | interface Item {
118 | url: string;
119 | name: string;
120 | parent: string;
121 | itemType: 'Container' | 'Resource';
122 | links?: Links;
123 | }
124 |
125 | interface FolderData {
126 | url: string;
127 | name: string;
128 | parent: string;
129 | links: Links;
130 | type: 'folder';
131 | folders: Item[];
132 | files: Item[];
133 | }
134 | }
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
--------------------------------------------------------------------------------
/public/vendor/plyr/plyr.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/Components/Navbar/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent } from 'react';
2 | import AppBar from '@material-ui/core/AppBar';
3 | import Toolbar from '@material-ui/core/Toolbar';
4 | import Typography from '@material-ui/core/Typography';
5 | import InputBase from '@material-ui/core/InputBase';
6 | import IconButton from '@material-ui/core/IconButton';
7 | import { fade } from '@material-ui/core/styles/colorManipulator';
8 | import { withStyles, Theme, createStyles, WithStyles } from '@material-ui/core/styles';
9 | import SearchIcon from '@material-ui/icons/Search';
10 | import RefreshIcon from '@material-ui/icons/Refresh';
11 | import { connect } from 'react-redux';
12 | import { refreshItemList, moveFolderUpwardsAndRefresh, filterItems, MyDispatch } from '../../Actions/Actions';
13 | import ThreeDotsMenu from './ThreeDotsMenu';
14 | import BreadcrumbText from '../Breadcrumb/BreadcrumbText';
15 | import { AppState } from '../../Reducers/reducer';
16 |
17 | const styles = (theme: Theme) => createStyles({
18 | root: {
19 | width: '100%',
20 | marginBottom: '4.3em'
21 | },
22 | grow: {
23 | flexGrow: 1,
24 | },
25 | menuButton: {
26 | marginLeft: -12,
27 | marginRight: 20,
28 | },
29 | title: {
30 | display: 'block', // was none
31 | [theme.breakpoints.up('sm')]: {
32 | display: 'block',
33 | },
34 | },
35 | search: {
36 | position: 'relative',
37 | borderRadius: theme.shape.borderRadius,
38 | backgroundColor: fade(theme.palette.common.white, 0.15),
39 | '&:hover': {
40 | backgroundColor: fade(theme.palette.common.white, 0.25),
41 | },
42 | marginLeft: 0,
43 | width: '100%',
44 | display: 'none',
45 | [theme.breakpoints.up('sm')]: {
46 | marginLeft: theme.spacing(),
47 | width: 'auto',
48 | display: 'block'
49 | },
50 | },
51 | searchIcon: {
52 | width: theme.spacing() * 9,
53 | height: '100%',
54 | position: 'absolute',
55 | pointerEvents: 'none',
56 | display: 'flex',
57 | alignItems: 'center',
58 | justifyContent: 'center',
59 | },
60 | inputRoot: {
61 | color: 'inherit',
62 | width: '100%',
63 | },
64 | inputInput: {
65 | paddingTop: theme.spacing(),
66 | paddingRight: theme.spacing(),
67 | paddingBottom: theme.spacing(),
68 | paddingLeft: theme.spacing() * 10,
69 | transition: theme.transitions.create('width'),
70 | width: '100%',
71 | [theme.breakpoints.up('sm')]: {
72 | width: 100,
73 | '&:focus': {
74 | width: 200,
75 | },
76 | },
77 | },
78 | });
79 |
80 | function SearchAppBar(props: SearchAppBarProps) {
81 | const { classes, path, filter, moveUpwards, canGoBack, handleChange, handleRefresh } = props;
82 | return (
83 |
84 |
85 |
86 |
87 | moveUpwards(path.length - index - 1)}
90 | handleGoBack={() => moveUpwards(1)}
91 | canGoBack={canGoBack}
92 | rootTitle="Solid Filemanager"
93 | />
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | );
119 | }
120 |
121 | interface StateProps {
122 | filter: string;
123 | path: string[];
124 | canGoBack: boolean;
125 | }
126 | interface DispatchProps {
127 | handleChange(event: ChangeEvent): void;
128 | moveUpwards(n: number): void;
129 | handleRefresh(): void;
130 | }
131 | interface SearchAppBarProps extends StateProps, DispatchProps, WithStyles {
132 |
133 | }
134 |
135 |
136 | const mapStateToProps = (state: AppState): StateProps => {
137 | return {
138 | filter: state.items.filter,
139 | path: state.path,
140 | canGoBack: state.path.length > 0,
141 | };
142 | };
143 |
144 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
145 | return {
146 | handleChange: (event) => {
147 | dispatch(filterItems(event.currentTarget.value));
148 | },
149 | moveUpwards: (n) => {
150 | console.log('moveUpwards', n);
151 | dispatch(moveFolderUpwardsAndRefresh(n));
152 | },
153 | handleRefresh: () => dispatch(refreshItemList())
154 | };
155 | };
156 |
157 |
158 | export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(SearchAppBar));
159 |
--------------------------------------------------------------------------------
/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read http://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config: ServiceWorkerConfig) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl: string, config: ServiceWorkerConfig) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl: string, config: ServiceWorkerConfig) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
137 |
138 | export interface ServiceWorkerConfig {
139 | onUpdate(registration: ServiceWorkerRegistration): void;
140 | onSuccess(registration: ServiceWorkerRegistration): void;
141 | }
--------------------------------------------------------------------------------
/src/Components/Dialogs/Menu/Menu.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import DialogTitle from '@material-ui/core/DialogTitle';
7 | import TextField from '@material-ui/core/TextField';
8 | import Typography from '@material-ui/core/Typography';
9 | import { connect } from 'react-redux';
10 | import { solidLogin, setHost, enterFolder, solidLogout, clearCache, MyDispatch, setErrorMessage, closeDialog } from '../../../Actions/Actions';
11 | import { getLocationObjectFromUrl } from '../../HistoryHandler/HistoryHandler';
12 | import { DialogButtonClickEvent, DialogDispatchProps, DialogStateProps } from '../dialogTypes';
13 | import { AppState } from '../../../Reducers/reducer';
14 | import { DIALOGS } from '../../../Actions/actionTypes';
15 |
16 | class FormDialog extends Component {
17 | state = {
18 | location: '',
19 | oidcIssuer: '',
20 | };
21 |
22 | componentWillReceiveProps(props: ChooseLocationProps) {
23 | const { isLoggedIn, webId } = props;
24 | const params = new URLSearchParams(document.location.search.substr(1));
25 | const encodedUrl = params.get('url');
26 |
27 | this.setState({ oidcIssuer: 'https://solidcommunity.net/'})
28 | if (encodedUrl !== null) {
29 | const location = decodeURI(encodedUrl);
30 | this.setState({ location });
31 | }
32 | else if (isLoggedIn && webId) {
33 | const location = (new URL(webId)).origin;
34 | this.setState({ location });
35 | }
36 | }
37 |
38 | handleChange(event: React.ChangeEvent) {
39 | this.setState({ location: event.target.value })
40 | }
41 |
42 | handleIDProviderChange(event: React.ChangeEvent) {
43 | this.setState({ oidcIssuer: event.target.value })
44 | }
45 |
46 | handleSubmit(event: DialogButtonClickEvent) {
47 | this.props.handleSubmit(event, { location: this.state.location });
48 | }
49 |
50 | handleLogin(event: DialogButtonClickEvent) {
51 | this.props.handleLogin(event, { oidcIssuer: this.state.oidcIssuer })
52 | }
53 |
54 | render() {
55 | let { location, oidcIssuer } = this.state;
56 | location = location ? location : '';
57 | const { handleClose, handleLogout, open, isLoggedIn, webId } = this.props;
58 |
59 | return (
60 |
61 |
105 |
106 | );
107 | }
108 | }
109 |
110 | interface StateProps extends DialogStateProps {
111 | webId: string | null;
112 | isLoggedIn: boolean;
113 | }
114 | interface DispatchProps extends DialogDispatchProps {
115 | handleLogin(event: DialogButtonClickEvent, { oidcIssuer }: { oidcIssuer: string }): void;
116 | handleLogout(event: DialogButtonClickEvent): void;
117 | handleSubmit(event: DialogButtonClickEvent, { location }: { location: string }): void;
118 | }
119 | interface ChooseLocationProps extends StateProps, DispatchProps { }
120 |
121 |
122 | const mapStateToProps = (state: AppState): StateProps => {
123 | return {
124 | open: state.visibleDialogs.CHOOSE_LOCATION,
125 | webId: state.account.webId,
126 | isLoggedIn: state.account.loggedIn
127 | };
128 | };
129 |
130 | const mapDispatchToProps = (dispatch: MyDispatch): DispatchProps => {
131 | return {
132 | handleClose: () => {
133 | dispatch(closeDialog(DIALOGS.CHOOSE_LOCATION));
134 | },
135 | handleLogin: (event, { oidcIssuer }) => {
136 | event.preventDefault();
137 | dispatch(solidLogin(oidcIssuer));
138 | },
139 | handleLogout: event => {
140 | event.preventDefault();
141 | dispatch(solidLogout());
142 | },
143 | handleSubmit: (event, { location }) => {
144 | event.preventDefault();
145 | if (!location)
146 | return dispatch(setErrorMessage("Please enter the folder which should be opened"));
147 |
148 | const { host, path } = getLocationObjectFromUrl(location);
149 | dispatch(closeDialog(DIALOGS.CHOOSE_LOCATION));
150 | dispatch(setHost(host));
151 | dispatch(clearCache());
152 | dispatch(enterFolder(path));
153 | }
154 | };
155 | };
156 |
157 | export default connect(mapStateToProps, mapDispatchToProps)(FormDialog);
158 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add('login', (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
26 |
27 | const { createDpopHeader, generateDpopKeyPair, buildAuthenticatedFetch } = require('@inrupt/solid-client-authn-core')
28 | const uuid = require('uuid')
29 | const cssBaseUrl = 'http://localhost:8080'
30 |
31 |
32 | /**
33 | * pretends to be a normal fetch
34 | * this can be passed to buildAuthenticatedFetch
35 | * and it will resolve with { options }
36 | *
37 | * this is used to get the valid authentication headers from buildAuthenticatedFetch
38 | */
39 | const cyFetchWrapper = (url, options = {}) => {
40 | options.method ??= 'GET'
41 | options.url = url
42 | options.headers = Object.fromEntries(options.headers.entries())
43 | // mock response
44 | return {
45 | ok: true, // buildAUthenticatedFetch relies on response.ok to be true. Else it checks for unauthorized errors
46 | options,
47 | }
48 | }
49 |
50 | /**
51 | * uses the authenticatio headers from cyFetchWrapper
52 | * and makes a cy.request
53 | */
54 | const cyUnwrapFetch = wrappedFetch => {
55 | return async (...args) => {
56 | const res = await wrappedFetch(...args)
57 | return cy.request(res.options)
58 | }
59 | }
60 |
61 | Cypress.Commands.add('createRandomAccount', () => {
62 | const username = 'test-' + uuid.v4()
63 | const password = '12345'
64 | const email = `${username}@example.org`
65 | const config = {
66 | idp: `${cssBaseUrl}/`,
67 | podUrl: `${cssBaseUrl}/${username}`,
68 | webId: `${cssBaseUrl}/${username}/profile/card#me`,
69 | username,
70 | password,
71 | email,
72 | }
73 | const registerEndpoint = `${cssBaseUrl}/idp/register/`
74 | cy.request('POST', registerEndpoint, {
75 | createWebId: 'on',
76 | webId: '',
77 | register: 'on',
78 | createPod: 'on',
79 | podName: username,
80 | email,
81 | password,
82 | confirmPassword: password,
83 | })
84 |
85 | return cy.wrap(config)
86 | })
87 |
88 | /**
89 | * Manually logins the user
90 | * Assumes it is previously not logged in
91 | */
92 | Cypress.Commands.add('login', user => {
93 | const typeFastConfig = {
94 | delay: 0
95 | }
96 |
97 | cy.log('login', user)
98 | cy.visit('/')
99 | cy.get('[data-cy=idp]')
100 | .clear()
101 | .type(user.idp, typeFastConfig)
102 | cy.contains('Login').click()
103 |
104 | cy.url().should('include', user.idp)
105 | cy.get('label').contains('Email').click()
106 | .type(user.email, typeFastConfig)
107 | cy.get('label').contains('Password').click()
108 | .type(user.password, typeFastConfig)
109 | cy.contains('button', 'Log in').click()
110 |
111 | cy.url().should('include', '/consent')
112 | cy.contains('button', 'Consent').click()
113 |
114 | cy.url().should('include', `${Cypress.config().baseUrl}/`)
115 | // workaround to wait for the input being input automatically, so the clear works
116 | cy.get('[data-cy=storageLocation]').should('have.value', user.idp.slice(0, -1))
117 | cy.get('[data-cy=storageLocation]')
118 | .clear()
119 | .type(user.podUrl, typeFastConfig)
120 | cy.contains('Open directory').click()
121 |
122 | cy.contains('profile')
123 | })
124 |
125 | Cypress.Commands.add('authenticatedFetch', (user, ...args) => {
126 | return cy.getAuthenticatedFetch(user)
127 | .then(fetch => fetch(...args))
128 | })
129 |
130 | Cypress.Commands.add('getAuthenticatedFetch', user => {
131 | // see https://github.com/CommunitySolidServer/CommunitySolidServer/blob/main/documentation/client-credentials.md
132 | const credentialsEndpoint = `${cssBaseUrl}/idp/credentials/`
133 | return cy.request('POST', credentialsEndpoint, {
134 | email: user.email,
135 | password: user.password,
136 | name: 'cypress-login-token',
137 | }).then(async response => {
138 | const { id, secret } = response.body
139 | const dpopKey = await generateDpopKeyPair()
140 | const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`
141 | const tokenEndpoint = `${cssBaseUrl}/.oidc/token`
142 | cy.request({
143 | method: 'POST',
144 | url: tokenEndpoint,
145 | headers: {
146 | authorization: `Basic ${Buffer.from(authString).toString('base64')}`,
147 | 'content-type': 'application/x-www-form-urlencoded',
148 | dpop: await createDpopHeader(tokenEndpoint, 'POST', dpopKey),
149 | },
150 | body: 'grant_type=client_credentials&scope=webid',
151 | }).then(async response => {
152 | const {access_token: accessToken } = response.body
153 | const authFetchWrapper = await buildAuthenticatedFetch(cyFetchWrapper, accessToken, { dpopKey })
154 | const authFetch = cyUnwrapFetch(authFetchWrapper)
155 | return cy.wrap(authFetch)
156 | })
157 | })
158 |
159 | })
160 |
161 | Cypress.Commands.add('inputFromLabel', label => {
162 | return cy.contains('label', label)
163 | .invoke('attr', 'for')
164 | .then(id => cy.get('#' + id))
165 | })
166 |
167 | /** recursively creates folder and leaves a .test.keep file inside
168 | * requires permissions to do so */
169 | Cypress.Commands.add('givenFolder', (user, url) => {
170 | if (!url.endsWith('/'))
171 | url += '/'
172 |
173 | // putting file which recursively creates parent containers
174 | const tempFileUrl = url + '.test.keep'
175 | cy.authenticatedFetch(user, url, {
176 | method: 'PUT',
177 | headers: {
178 | 'content-type': 'text/plain'
179 | }
180 | })
181 | /* somehow this also deletes the created folders
182 | cy.authenticatedFetch(user, url, {
183 | method: 'DELETE',
184 | })
185 | */
186 | })
187 |
188 | Cypress.Commands.add('givenTextFile', (user, url, content, contentType = 'text/plain') => {
189 | cy.authenticatedFetch(user, url, {
190 | method: 'PUT',
191 | headers: {
192 | 'content-type': contentType,
193 | },
194 | body: content
195 | })
196 | })
197 |
198 | Cypress.Commands.add('givenBlob', (user, url, blob) => {
199 | cy.authenticatedFetch(user, url, {
200 | method: 'PUT',
201 | headers: {
202 | 'content-type': blob.type,
203 | },
204 | body: blob
205 | })
206 | })
--------------------------------------------------------------------------------
/cypress/integration/zip_spec.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const JSZip = require('jszip')
3 |
4 | describe('zip operations', () => {
5 | beforeEach('login user', () => {
6 | cy.createRandomAccount().as('user')
7 | })
8 |
9 | it('can download multiple files as a zip', function () {
10 | const files = [{
11 | name: 'first.txt',
12 | content: 'some test',
13 | }, {
14 | name: 'second.txt',
15 | content: 'other test',
16 | }];
17 | for (const file of files) {
18 | const fileUrl = `${this.user.podUrl}/${file.name}`
19 | cy.givenTextFile(this.user, fileUrl, file.content)
20 | }
21 | cy.login(this.user)
22 |
23 | // select files
24 | for (const file of files) {
25 | cy.contains(file.name).click({ ctrlKey: true })
26 | }
27 |
28 | // right click any of those files and download zip
29 | cy.contains(files[0].name).rightclick()
30 | cy.contains("Download Zip").click()
31 |
32 | const zipPath = path.join(Cypress.config("downloadsFolder"), "Archive.zip");
33 | cy.readFile(zipPath, 'base64').then(async (file) => {
34 | const zip = await JSZip.loadAsync(file, { base64: true })
35 | expect(Object.keys(zip.files).length).to.eq(2)
36 | expect(await zip.file('first.txt').async('string')).to.eq('some test')
37 | expect(await zip.file('second.txt').async('string')).to.eq('other test')
38 | })
39 | })
40 |
41 | it('can download a folder as a zip', function () {
42 | const folderName = 'some-folder'
43 | const folderUrl = `${this.user.podUrl}/${folderName}`
44 | cy.givenFolder(this.user, folderUrl)
45 | cy.givenTextFile(this.user, `${folderUrl}/shallow.txt`, 'shallow content')
46 | cy.givenFolder(this.user, `${folderUrl}/nested`)
47 | cy.givenTextFile(this.user, `${folderUrl}/nested/deep.txt`, 'nested content')
48 |
49 | cy.login(this.user)
50 |
51 | cy.contains(folderName).rightclick()
52 | cy.contains("Download Zip").click()
53 |
54 | const zipPath = path.join(Cypress.config("downloadsFolder"), `${folderName}.zip`);
55 | cy.readFile(zipPath, 'base64').then(async (file) => {
56 | const zip = await JSZip.loadAsync(file, { base64: true })
57 | expect(Object.keys(zip.files).length).to.eq(4)
58 | expect(await zip.folder('some-folder').file('shallow.txt').async('string')).to.eq('shallow content')
59 | expect(await zip.folder('some-folder').folder('nested').file('deep.txt').async('string')).to.eq('nested content')
60 | })
61 | })
62 |
63 | it('can extract a nested zip', function () {
64 | // prepare my-archive.zip on the pod
65 | const zip = new JSZip()
66 | zip.file('shallow.txt', 'shallow content')
67 | zip.file('nested/deep.txt', 'nested content')
68 | cy.wrap(null).then(async () => {
69 | const blob = await zip.generateAsync({ type: 'blob' });
70 | cy.givenBlob(this.user, `${this.user.podUrl}/my-archive.zip`, blob);
71 | })
72 |
73 | cy.login(this.user)
74 |
75 | // Extract
76 | cy.contains('my-archive.zip').rightclick()
77 | cy.contains('Extract here').click()
78 |
79 | // verify the files are uploaded and thus accessible via the user interface
80 | cy.contains('shallow.txt')
81 | cy.contains('nested').dblclick()
82 | cy.contains('deep.txt').dblclick()
83 | cy.contains('nested content')
84 | })
85 |
86 | it('can download ACL files when enabled', function () {
87 | const folderName = 'test-folder-2'
88 | const folderUrl = `${this.user.podUrl}/${folderName}`
89 | const acl = `
90 | @prefix : <${folderUrl}/file.txt.acl#>.
91 | @prefix acl: .
92 | @prefix foaf: .
93 |
94 | :ControlReadWrite
95 | a acl:Authorization;
96 | acl:accessTo <./file.txt>;
97 | acl:agentClass foaf:Agent;
98 | acl:mode acl:Control, acl:Read, acl:Write.`
99 | cy.givenFolder(this.user, folderUrl)
100 | cy.givenTextFile(this.user, `${folderUrl}/file.txt`, 'some content')
101 | cy.givenTextFile(this.user, `${folderUrl}/file.txt.acl`, acl, 'text/turtle')
102 |
103 | cy.login(this.user)
104 |
105 | cy.get('[data-cy=three-dots-menu]').click()
106 | cy.contains('Settings').click()
107 | cy.contains('include .acl files').click()
108 | cy.contains('Close').click()
109 |
110 | cy.contains(folderName).rightclick()
111 | cy.contains("Download Zip").click()
112 |
113 | const zipPath = path.join(Cypress.config("downloadsFolder"), `${folderName}.zip`);
114 | cy.readFile(zipPath, 'base64').then(async (file) => {
115 | const zip = await JSZip.loadAsync(file, { base64: true })
116 | expect(Object.keys(zip.files).length).to.eq(3)
117 | expect(await zip.folder(folderName).file('file.txt').async('string')).to.eq('some content')
118 | expect(await zip.folder(folderName).file('file.txt.acl').async('string')).to.eq(acl)
119 | })
120 | })
121 |
122 | it('does not extract .acl files per default', function () {
123 | const folderName = 'test-folder-3'
124 | const folderUrl = `${this.user.podUrl}/${folderName}`
125 | const acl = `
126 | @prefix : <${folderUrl}/file.txt.acl#>.
127 | @prefix acl: .
128 | @prefix foaf: .
129 |
130 | :ControlReadWrite
131 | a acl:Authorization;
132 | acl:accessTo <${folderUrl}/file.txt>;
133 | acl:agentClass foaf:Agent;
134 | acl:mode acl:Control, acl:Read, acl:Write.`
135 |
136 | // prepare my-archive.zip on the pod
137 | const zip = new JSZip()
138 | zip.file(`${folderName}/file.txt`, 'nested content')
139 | zip.file(`${folderName}/file.txt.acl`, acl)
140 | // zip.file(`${folderName}/file.txt.acl`, acl)
141 | cy.wrap(null).then(async () => {
142 | const blob = await zip.generateAsync({ type: 'blob' });
143 | cy.givenBlob(this.user, `${this.user.podUrl}/my-archive.zip`, blob);
144 | })
145 |
146 | cy.login(this.user)
147 |
148 | // Extract
149 | cy.contains('my-archive.zip').rightclick()
150 | cy.contains('Extract here').click()
151 |
152 | // verify the files are uploaded and thus accessible via the user interface
153 | cy.contains(folderName).dblclick()
154 |
155 | // verify ACL file does not exist exists, and thus the file is not publicly accessible
156 | cy.request({
157 | url: `${folderUrl}/file.txt`,
158 | failOnStatusCode: false
159 | }).then(res => expect(res.status).to.eq(401))
160 | })
161 |
162 | it('can extract .acl files when enabled', function () {
163 | const folderName = 'test-folder-1'
164 | const folderUrl = `${this.user.podUrl}/${folderName}`
165 | const acl = `
166 | @prefix : <${folderUrl}/file.txt.acl#>.
167 | @prefix acl: .
168 | @prefix foaf: .
169 |
170 | :ControlReadWrite
171 | a acl:Authorization;
172 | acl:accessTo <${folderUrl}/file.txt>;
173 | acl:agentClass foaf:Agent;
174 | acl:mode acl:Control, acl:Read, acl:Write.`
175 |
176 | // prepare my-archive.zip on the pod
177 | const zip = new JSZip()
178 | zip.file(`${folderName}/file.txt`, 'nested content')
179 | zip.file(`${folderName}/file.txt.acl`, acl)
180 | cy.wrap(null).then(async () => {
181 | const blob = await zip.generateAsync({ type: 'blob' });
182 | cy.givenBlob(this.user, `${this.user.podUrl}/my-archive.zip`, blob);
183 | })
184 |
185 | cy.login(this.user)
186 |
187 | cy.get('[data-cy=three-dots-menu]').click()
188 | cy.contains('Settings').click()
189 | cy.contains('include .acl files').click()
190 | cy.contains('Close').click()
191 |
192 | // Extract
193 | cy.contains('my-archive.zip').rightclick()
194 | cy.contains('Extract here').click()
195 |
196 | // verify the files are uploaded and thus accessible via the user interface
197 | cy.contains(folderName).dblclick()
198 |
199 | // verify that the ACL file exists, by trying to use the public access
200 | cy.request(`${folderUrl}/file.txt`)
201 | })
202 | })
203 |
--------------------------------------------------------------------------------
/src/Api/ApiHandler.ts:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip';
2 | import { FileItem, FolderItem, Item } from './Item';
3 | import ApiCache from './ApiCache';
4 | import config from './../config';
5 | import SolidFileClient from 'solid-file-client';
6 | import { guessContentType } from './contentTypes';
7 | import { fetch } from '@inrupt/solid-client-authn-browser';
8 |
9 | const fileClient = new SolidFileClient({ fetch }, { enableLogging: true });
10 | const cache = new ApiCache();
11 |
12 | /**
13 | * Log a fetch response error and throw it again
14 | * @param {*} error
15 | */
16 | const handleFetchError = async (error: Error | Response | string) => {
17 | let detailedErrorMessage = '';
18 | let displayErrorMessage: string | undefined;
19 |
20 | console.group('handleFetchError');
21 | if (error instanceof Response) {
22 | detailedErrorMessage = await error.text();
23 |
24 | console.error(`url: ${error.url}`);
25 | console.error(`status: ${error.status}`);
26 |
27 | const displayMessages: Record = {
28 | '401': `The ressource at ${error.url} requires you to login.`,
29 | '403': `You don't have permission to access the ressource at ${error.url}.
30 | Please make sure that you are logged in with the correct account.
31 | If the server runs with version 5.0.0 or higher, make sure you gave this app read/write permission`,
32 | '404': `The ressource at ${error.url} was not found`,
33 | '500': `An internal server error occured...
34 | ${detailedErrorMessage}`,
35 | };
36 | if (error.status in displayMessages)
37 | displayErrorMessage = displayMessages[error.status];
38 | }
39 | else if (error instanceof Error) {
40 | detailedErrorMessage = error.message;
41 | console.error(error.stack);
42 | }
43 | else if (typeof error === 'string') {
44 | detailedErrorMessage = error;
45 | }
46 | else {
47 | detailedErrorMessage = JSON.stringify(error);
48 | }
49 | console.error(`errorMessage: ${detailedErrorMessage}`);
50 | console.error(`error: ${error}`);
51 | console.groupEnd();
52 |
53 | throw new Error((displayErrorMessage) ? displayErrorMessage : detailedErrorMessage);
54 | }
55 |
56 | /**
57 | * Clean path string removing double slashes and prepending a slash if non-empty
58 | */
59 | const fixPath = (path: string): string => {
60 | if (path === "")
61 | return path;
62 | return ('/' + path).replace(/\/\//g, '/');
63 | };
64 |
65 | /**
66 | * Wrap API response for retrieving item list
67 | * itemList is cached automatically
68 | * @param {String} path
69 | * @returns {Promise- }
70 | */
71 | export const getItemList = async (path: string): Promise
- => {
72 | path = fixPath(path);
73 | if (cache.contains(path))
74 | return cache.get(path);
75 |
76 | try {
77 | const url = buildFolderUrl(path);
78 | const folderData = await fileClient.readFolder(url, { links: SolidFileClient.LINKS.EXCLUDE })
79 | const itemList = [
80 | ...folderData.files.map(item => new FileItem(item.url)), // TODO: item.size
81 | ...folderData.folders.map(item => new FolderItem(item.url)) // TODO: item.size
82 | ]
83 | cache.add(path, itemList);
84 | return itemList
85 | } catch (err) {
86 | throw await handleFetchError(err);
87 | }
88 | };
89 |
90 | export const clearCacheForFolder = (path: string) => cache.remove(fixPath(path));
91 | export const clearCache = () => cache.clear();
92 |
93 | /**
94 | * Wrap API response for retrieving file content
95 | */
96 | export const getFileBlob = async (path: string, filename: string): Promise
=> {
97 | path = fixPath(path);
98 | try {
99 | const res = await fileClient.get(buildFileUrl(path, filename));
100 | return res.blob();
101 | } catch (err) {
102 | throw await handleFetchError(err);
103 | }
104 | };
105 |
106 |
107 | /**
108 | * Wrap API response for renaming a file
109 | */
110 | export const renameFile = (path: string, fileName: string, newFileName: string): Promise => {
111 | path = fixPath(path);
112 | cache.remove(path);
113 | return fileClient.rename(buildFileUrl(path, fileName), newFileName)
114 | .then(res => Array.isArray(res) ? res[0] : res)
115 | .catch(handleFetchError)
116 | };
117 |
118 |
119 | /**
120 | * Wrap API response for renaming a folder
121 | */
122 | export const renameFolder = (path: string, folderName: string, newFolderName: string): Promise => {
123 | path = fixPath(path);
124 | cache.remove(path);
125 | return fileClient.rename(buildFolderUrl(path, folderName), newFolderName)
126 | .then(res => Array.isArray(res) ? res[0] : res)
127 | .catch(handleFetchError)
128 | };
129 |
130 | /**
131 | * Wrap API response for creating a folder
132 | */
133 | export const createFolder = (path: string, folderName: string): Promise => {
134 | path = fixPath(path);
135 | cache.remove(path);
136 | if (!(folderName || '').trim()) {
137 | return Promise.reject('Invalid folder name');
138 | }
139 | return fileClient.createFolder(buildFolderUrl(path, folderName), {
140 | merge: SolidFileClient.MERGE.KEEP_TARGET
141 | })
142 | .catch(handleFetchError)
143 | };
144 |
145 | /**
146 | * Fetch API to remove one item
147 | */
148 | export async function removeItem(path: string, itemName: string): Promise { // TODO: use fileClient
149 | const url = buildFileUrl(path, itemName);
150 |
151 | return fileClient.delete(url)
152 | .catch(err => {
153 | if (err.status === 409 || err.status === 301) {
154 | // Solid pod returns 409 if the item is a folder and is not empty
155 | // Solid pod returns 301 if is attempted to read a folder url without '/' at the end (from buildFileUrl)
156 | return fileClient.deleteFolderRecursively(buildFolderUrl(path, itemName));
157 | }
158 | else if (err.status === 404) {
159 | // Don't throw if the item didn't exist
160 | return err;
161 | }
162 | else
163 | throw err
164 | })
165 | }
166 |
167 | /**
168 | * Wrap API response for removing a file or folder
169 | */
170 | export const removeItems = (path: string, filenames: string[]): Promise => {
171 | path = fixPath(path);
172 | cache.remove(path);
173 | if (!filenames.length) {
174 | return Promise.reject('No files to remove');
175 | }
176 | return Promise.all(filenames.map(name => removeItem(path, name)))
177 | .catch(handleFetchError);
178 | };
179 |
180 | /**
181 | * Wrap API response for moving a file or folder
182 | */
183 | export const moveItems = (path: string, destination: string, filenames: string[]): Promise => {
184 | path = fixPath(path);
185 | destination = fixPath(destination);
186 | cache.remove(path, destination);
187 | if (!filenames.length) {
188 | return Promise.reject('No files to move');
189 | }
190 |
191 | return copyItems(path, destination, filenames)
192 | .then(res => removeItems(path, filenames))
193 | .catch(handleFetchError)
194 | };
195 |
196 | /**
197 | * Wrap API response for copying a file or folder
198 | */
199 | export const copyItems = async (path: string, destination: string, filenames: string[]): Promise => {
200 | path = fixPath(path);
201 | destination = fixPath(destination);
202 | cache.remove(path, destination);
203 | if (!filenames.length) {
204 | return Promise.reject('No files to copy');
205 | }
206 |
207 | const items = await getItemList(path)
208 | .then(items => items.filter(({ name }) => filenames.includes(name)))
209 | const promises: Promise<(Response | Response[])>[] = []
210 | for (const item of items) {
211 | if (item instanceof FolderItem) {
212 | promises.push(fileClient.copyFolder(buildFolderUrl(path, item.name), buildFolderUrl(destination, item.name), {
213 | withAcl: false,
214 | withMeta: true,
215 | createPath: true,
216 | merge: SolidFileClient.MERGE.KEEP_SOURCE
217 | }))
218 | } else {
219 | promises.push(fileClient.copyFile(buildFileUrl(path, item.name), buildFileUrl(destination, item.name), {
220 | withAcl: false,
221 | withMeta: true,
222 | createPath: true,
223 | merge: SolidFileClient.MERGE.REPLACE
224 | }))
225 | }
226 | }
227 |
228 | return Promise.all(promises)
229 | .then(responses => responses.flat(1))
230 | .catch(handleFetchError);
231 | };
232 |
233 | /**
234 | * Wrap API response for uploading files
235 | */
236 | export const uploadFiles = async (path: string, fileList: FileList): Promise => {
237 | path = fixPath(path);
238 | cache.remove(path);
239 |
240 | if (!fileList.length) {
241 | return Promise.reject('No files to upload');
242 | }
243 | const promises = Array.from(fileList).map(async file => {
244 | const contentType = file.type || (await guessContentType(file.name, file))
245 | return updateFile(path, file.name, file, contentType)
246 | });
247 | return Promise.all(promises).catch(handleFetchError);
248 | };
249 |
250 | /**
251 | * Wrap API response for uploading a file
252 | */
253 | export const updateFile = (path: string, fileName: string, content: Blob | string, contentType: string): Promise => {
254 | path = fixPath(path);
255 | cache.remove(path);
256 | return fileClient.putFile(buildFileUrl(path, fileName), content, contentType)
257 | .catch(handleFetchError);
258 | };
259 |
260 | /**
261 | * Wrap API response for zipping multiple items
262 | */
263 | export const getAsZip = async (path: string, itemList: Item[]): Promise => {
264 | path = fixPath(path);
265 |
266 | try {
267 | if (config.withAcl() || config.withMeta()) {
268 | // SFC only supports single url
269 | if (itemList.length !== 1) {
270 | throw new Error(`Please select exactly one item when zipping with ACL/Meta enabled.`)
271 | }
272 | const item = itemList[0];
273 | const url = (item instanceof FolderItem) ? buildFolderUrl(path, item.name) : buildFileUrl(path, item._name)
274 | const zip = await fileClient.getAsZip(url, {
275 | withAcl: config.withAcl(),
276 | withMeta: config.withMeta(),
277 | })
278 | return zip;
279 | } else {
280 | const zip = new JSZip();
281 |
282 | await addItemsToZip(zip, path, itemList);
283 | return zip;
284 | }
285 | } catch (err) {
286 | throw await handleFetchError(err as any);
287 | }
288 | }
289 |
290 | /**
291 | * Add items to a zip object recursively
292 | */
293 | const addItemsToZip = (zip: JSZip, path: string, itemList: Item[]): Promise => {
294 | const promises = itemList.map(async item => {
295 | if (item instanceof FolderItem) {
296 | const zipFolder = zip.folder(item.name) as JSZip;
297 | const folderPath = `${path}/${item.name}`;
298 | const folderItems = await getItemList(folderPath);
299 | await addItemsToZip(zipFolder, folderPath, folderItems);
300 | }
301 | else if (item instanceof FileItem) {
302 | const blob = await getFileBlob(path, item.name);
303 | zip.file(item.name, blob, { binary: true });
304 | }
305 | });
306 |
307 | return Promise.all(promises);
308 | }
309 |
310 | /**
311 | * Wrap API response for extracting a zip archive
312 | */
313 | export const extractZipArchive = async (path: string, destination: string = path, fileName: string) => {
314 | if (config.withAcl() || config.withMeta()) {
315 | const zipUrl = buildFileUrl(fixPath(path), fileName);
316 | const destinationUrl = buildFolderUrl(fixPath(destination))
317 | cache.remove(fixPath(destination))
318 | console.log(config.withAcl())
319 | const results = await fileClient.extractZipArchive(zipUrl, destinationUrl, {
320 | withAcl: config.withAcl(),
321 | withMeta: config.withMeta(),
322 | })
323 | if (results.err.length) throw await handleFetchError(new Error(`Could not extract all files: ${JSON.stringify(results.err)}`))
324 | } else {
325 | const blob = await getFileBlob(path, fileName);
326 | const zip = await JSZip.loadAsync(blob);
327 |
328 | await uploadExtractedZipArchive(zip, destination);
329 | }
330 | };
331 |
332 | /**
333 | * Recursively upload all files and folders from an extracted zip archive
334 | * Ignore .acl and .meta files (use SFC version instead, if this is desired)
335 | */
336 | async function uploadExtractedZipArchive(zip: JSZip, destination: string, curFolder = ''): Promise {
337 | const promises = getItemsInZipFolder(zip, curFolder)
338 | .map(async item => {
339 | const relativePath = item.name;
340 | const itemName = getItemNameFromPath(relativePath);
341 | const path = getParentPathFromPath(`${destination}/${relativePath}`);
342 |
343 | if (item.dir) {
344 | await createFolder(path, itemName);
345 | await uploadExtractedZipArchive(zip, destination, relativePath);
346 | }
347 | else if (!item.name.endsWith('.acl') && !item.name.endsWith('.meta')) {
348 | const blob = await item.async('blob');
349 | const contentType = blob.type ? blob.type : await guessContentType(item.name, blob);
350 | await updateFile(path, itemName, blob, contentType);
351 | }
352 | });
353 |
354 | return Promise.all(promises);
355 | };
356 |
357 | function getItemsInZipFolder(zip: JSZip, folderPath: string): JSZip.JSZipObject[] {
358 | return Object.keys(zip.files)
359 | .filter(fileName => {
360 | // Only items in the current folder and subfolders
361 | const relativePath = fileName.slice(folderPath.length, fileName.length);
362 | if (!relativePath || fileName.slice(0, folderPath.length) !== folderPath)
363 | return false;
364 |
365 | // No items from subfolders
366 | if (relativePath.includes('/') && relativePath.slice(0, -1).includes('/'))
367 | return false;
368 |
369 | return true;
370 | })
371 | .map(key => zip.files[key]);
372 | };
373 |
374 | function getItemNameFromPath(path: string): string {
375 | path = path.endsWith('/') ? path.slice(0, -1) : path;
376 | return path.substr(path.lastIndexOf('/') + 1);
377 | }
378 |
379 | function getParentPathFromPath(path: string): string {
380 | path = path.endsWith('/') ? path.slice(0, -1) : path;
381 | path = path.substr(0, path.lastIndexOf('/'));
382 | return path;
383 | }
384 |
385 | /**
386 | * Build up an url from a path relative to the storage location and a folder name
387 | */
388 | function buildFolderUrl(path: string, folderName?: string): string {
389 | return buildFileUrl(path, folderName) + '/';
390 | }
391 |
392 |
393 | /**
394 | * Build up an url from a path relative to the storage location and a fileName
395 | */
396 | function buildFileUrl(path: string, fileName?: string): string {
397 | let url = `${config.getHost()}${path}/${fileName || ''}`;
398 | while (url.slice(-1) === '/')
399 | url = url.slice(0, -1);
400 |
401 | return url;
402 | }
403 |
--------------------------------------------------------------------------------
/src/Actions/Actions.ts:
--------------------------------------------------------------------------------
1 | import * as APIHandler from '../Api/ApiHandler';
2 | import { Item, FileItem, FolderItem } from '../Api/Item';
3 | import { Action, SET_LOGGED_IN, SET_LOGGED_OUT, SET_HOST, SET_ITEMS, SET_WEB_ID, SELECT_ITEMS, TOGGLE_SELECTED_ITEM, DESELECT_ITEM, FILTER_ITEMS, RESET_FILTER, DISPLAY_LOADING, STOP_LOADING, DIALOGS, OPEN_DIALOG, CLOSE_DIALOG, SET_LOADED_BLOB, SET_UPLOAD_FILE_LIST, SET_UPLOAD_FILE_PROGRESS, SET_PATH, MOVE_FOLDER_UPWARDS, RESET_LOADED_BLOB, RESET_HOST, RESET_WEB_ID, SET_ERROR_MESSAGE, OPEN_CONTEXT_MENU, CLOSE_CONTEXT_MENU, TOGGLE_WITH_ACL, TOGGLE_WITH_META } from './actionTypes';
4 | import { AppState } from '../Reducers/reducer';
5 | import { ThunkAction, ThunkDispatch } from 'redux-thunk';
6 | import { guessContentType } from '../Api/contentTypes';
7 | import { handleIncomingRedirect, login, logout } from '@inrupt/solid-client-authn-browser';
8 |
9 |
10 | export type MyThunk = ThunkAction>;
11 | export type MyDispatch = ThunkDispatch>;
12 |
13 | export const initApp = (): MyThunk => (dispatch, getState) => {
14 | console.log(`Starting Solid-Filemanager v${process.env.REACT_APP_VERSION}`);
15 | dispatch(updateLoginStatus());
16 | dispatch(openDialog(DIALOGS.CHOOSE_LOCATION));
17 | };
18 |
19 |
20 | export const solidLogin = (oidcIssuer: string): MyThunk => (dispatch, getState) => {
21 | dispatch(displayLoading());
22 |
23 | login({
24 | oidcIssuer,
25 | clientName: 'Solid File Manager',
26 | redirectUrl: window.location.href,
27 | });
28 | };
29 |
30 | export const updateLoginStatus = (): MyThunk => async (dispatch, getState) => {
31 | handleIncomingRedirect({ restorePreviousSession: true })
32 | .then(session => {
33 | console.log('handleIncomingRequest', session)
34 | if (session && session.isLoggedIn && session.webId) {
35 | dispatch(setLoggedIn());
36 | dispatch(setWebId(session.webId))
37 | } else {
38 | dispatch(setLoggedOut());
39 | dispatch(resetWebId())
40 | }
41 | })
42 | }
43 |
44 | export const solidLogout = (): MyThunk => (dispatch, getState) => {
45 | dispatch(displayLoading());
46 |
47 | logout()
48 | .then(() => {
49 | dispatch(resetPath());
50 | dispatch(resetItems());
51 | dispatch(resetSelectedItems());
52 | dispatch(setLoggedOut());
53 | dispatch(resetWebId());
54 |
55 | dispatch(openDialog(DIALOGS.CHOOSE_LOCATION));
56 | })
57 | .catch(r => dispatch(setErrorMessage(String(r))))
58 | .finally(() => dispatch(stopLoading()));
59 | };
60 |
61 | export const clearCache = (): MyThunk => (dispatch, getState) => APIHandler.clearCache();
62 |
63 |
64 | /**
65 | * Request API to get file list for the selected path then refresh UI
66 | */
67 | export const uploadFiles = (): MyThunk => (dispatch, getState) => {
68 | const { path, upload: { fileList } } = getState();
69 |
70 | if (fileList === null)
71 | return dispatch(setErrorMessage("Couldn't find files to upload"));
72 |
73 | dispatch(displayLoading());
74 | dispatch(resetSelectedItems());
75 | dispatch(setFileUploadProgress(50));
76 |
77 | APIHandler.uploadFiles(path.join('/'), fileList)
78 | .then(r => {
79 | dispatch(setFileUploadProgress(100));
80 | setTimeout(f => {
81 | dispatch(resetFileUploader());
82 | }, 300);
83 | dispatch(displayCurrentItemList());
84 | })
85 | .catch(r => dispatch(setErrorMessage(String(r))))
86 | .finally(() => dispatch(stopLoading()));
87 | };
88 |
89 |
90 | export const createFile = (fileName: string, contentType?: string): MyThunk => async (dispatch, getState) => {
91 | const { path } = getState();
92 | dispatch(displayLoading());
93 |
94 | contentType = contentType ? contentType : await guessContentType(fileName);
95 | APIHandler.updateFile(path.join('/'), fileName, new Blob(), contentType)
96 | .then(r => {
97 | dispatch(closeDialog(DIALOGS.CREATE_FILE));
98 | dispatch(displayCurrentItemList());
99 | dispatch(loadAndEditFile(fileName));
100 | return APIHandler.getItemList(path.join('/'));
101 | })
102 | .then(itemList => itemList.find(item => item.getDisplayName() === fileName))
103 | .then(item => {
104 | if (!item)
105 | throw new Error("Couldn't load created file for editing");
106 | dispatch(selectItem(item));
107 | dispatch(getFileContent(item.name));
108 | })
109 | .catch(r => dispatch(setErrorMessage(String(r))))
110 | .finally(() => dispatch(stopLoading()));
111 | };
112 |
113 | export const updateTextFile = (fileName: string, content: Blob|string, contentType?: string): MyThunk => async (dispatch, getState) => {
114 | const { path } = getState();
115 | dispatch(displayLoading());
116 |
117 | contentType = contentType ? contentType : await guessContentType(fileName, content);
118 | APIHandler.updateFile(path.join('/'), fileName, content, contentType)
119 | .then(r => {
120 | dispatch(closeDialog(DIALOGS.EDIT));
121 | dispatch(displayCurrentItemList());
122 | })
123 | .catch(r => dispatch(setErrorMessage(String(r))))
124 | .finally(() => dispatch(stopLoading()));
125 | }
126 |
127 | /**
128 | * Request API to display file list for the selected path
129 | */
130 | export const displayCurrentItemList = (): MyThunk => (dispatch, getState) => {
131 | const { path } = getState();
132 | dispatch(displayLoading());
133 | dispatch(resetSelectedItems());
134 | APIHandler.getItemList(path.join('/'))
135 | .then(items => dispatch(setItems(items)))
136 | .catch(r => dispatch(setErrorMessage(String(r))))
137 | .finally(() => dispatch(stopLoading()));
138 | };
139 |
140 | /**
141 | * Request API to reload the file list and then refresh UI
142 | */
143 | export const refreshItemList = (): MyThunk => (dispatch, getState) => {
144 | const { path } = getState();
145 | APIHandler.clearCacheForFolder(path.join('/'));
146 | return dispatch(displayCurrentItemList());
147 | };
148 |
149 |
150 | /**
151 | * Request API to rename file then dispatch defined events
152 | */
153 | export const renameFile = (fileName: string, newFileName: string): MyThunk => (dispatch, getState) => {
154 | const { path } = getState();
155 | dispatch(displayLoading());
156 |
157 | APIHandler.renameFile(path.join('/'), fileName, newFileName)
158 | .then(() => {
159 | dispatch(displayCurrentItemList());
160 | dispatch(closeDialog(DIALOGS.RENAME));
161 | })
162 | .catch(r => dispatch(setErrorMessage(String(r))))
163 | .finally(() => dispatch(stopLoading()));
164 | };
165 |
166 | /**
167 | * Request API to rename file then dispatch defined events
168 | */
169 | export const renameFolder = (folderName: string, newFolderName: string): MyThunk => (dispatch, getState) => {
170 | const { path } = getState();
171 | dispatch(displayLoading());
172 |
173 | APIHandler.renameFolder(path.join('/'), folderName, newFolderName)
174 | .then(() => {
175 | dispatch(displayCurrentItemList());
176 | dispatch(closeDialog(DIALOGS.RENAME));
177 | })
178 | .catch(r => dispatch(setErrorMessage(String(r))))
179 | .finally(() => dispatch(stopLoading()));
180 | };
181 |
182 | /**
183 | * Request API to download the specified items
184 | */
185 | export const downloadItems = (items: Item[]): MyThunk => async (dispatch, getState) => {
186 | const { path } = getState();
187 | dispatch(displayLoading());
188 |
189 | try {
190 | let blob;
191 | let downloadName = items[0].name;
192 | if (items.length === 1 && items[0] instanceof FileItem) {
193 | blob = await APIHandler.getFileBlob(path.join('/'), items[0].name);
194 | }
195 | else {
196 | const zip = await APIHandler.getAsZip(path.join('/'), items);
197 | blob = await zip.generateAsync({ type: 'blob' });
198 |
199 | if (items.length > 1)
200 | downloadName = 'Archive';
201 | downloadName = `${downloadName}.zip`;
202 | }
203 |
204 | promptDownload(blob, downloadName);
205 | }
206 | catch (e) {
207 | dispatch(setErrorMessage(String(e)));
208 | }
209 | dispatch(stopLoading());
210 | };
211 |
212 | /**
213 | * Request API to upload the items as zip archive
214 | */
215 | export const zipAndUpload = (items: Item[]): MyThunk => (dispatch, getState) => {
216 | const { path } = getState();
217 | dispatch(displayLoading());
218 |
219 | const archiveName = (items.length === 1 && items[0] instanceof FolderItem) ?
220 | `${items[0].name}.zip`
221 | : 'Archive.zip';
222 |
223 | APIHandler.getAsZip(path.join('/'), items)
224 | .then(zip => zip.generateAsync({ type: 'blob' }))
225 | .then(blob => APIHandler.updateFile(path.join('/'), archiveName, blob, 'application/zip'))
226 | .then(() => dispatch(displayCurrentItemList()))
227 | .catch(r => dispatch(setErrorMessage(String(r))))
228 | .finally(() => dispatch(stopLoading()));
229 | };
230 |
231 | /**
232 | * Request API for extracting a zip archive
233 | */
234 | export const extractZipFile = (fileName: string): MyThunk => (dispatch, getState) => {
235 | const { path } = getState();
236 | dispatch(displayLoading());
237 |
238 | APIHandler.extractZipArchive(path.join('/'), path.join('/'), fileName)
239 | .then(r => dispatch(displayCurrentItemList()))
240 | .catch(r => dispatch(setErrorMessage(String(r))))
241 | .finally(() => dispatch(stopLoading()));
242 | };
243 |
244 | // code based on https://stackoverflow.com/a/30832210/6548154
245 | function promptDownload(file: Blob, fileName: string) {
246 | if (window.navigator.msSaveOrOpenBlob) // IE10+
247 | window.navigator.msSaveOrOpenBlob(file, fileName);
248 | else { // Others
249 | const a = document.createElement("a");
250 | const url = URL.createObjectURL(file);
251 | a.href = url;
252 | a.download = fileName;
253 | document.body.appendChild(a);
254 | a.click();
255 | setTimeout(() => {
256 | document.body.removeChild(a);
257 | window.URL.revokeObjectURL(url);
258 | }, 0);
259 | }
260 | }
261 |
262 | /**
263 | * Opens the item in a new tab
264 | */
265 | export const openInNewTab = (item: Item): MyThunk => (dispatch, getState) => {
266 | window.open(item.url, '_blank');
267 | };
268 |
269 |
270 | /**
271 | * Request API to get file content then dispatch defined events
272 | */
273 | export const getFileContent = (fileName: string): MyThunk => (dispatch, getState) => {
274 | const { path } = getState();
275 | dispatch(displayLoading());
276 | dispatch(resetFileContent());
277 |
278 | APIHandler.getFileBlob(path.join('/'), fileName)
279 | .then(blob => dispatch(setFileContent(blob)))
280 | .catch(r => dispatch(setErrorMessage(String(r))))
281 | .finally(() => dispatch(stopLoading()));
282 | };
283 |
284 |
285 | /**
286 | * Request API to get file content and open the edit dialogue
287 | */
288 | export const loadAndEditFile = (fileName: string): MyThunk => (dispatch, getState) => {
289 | dispatch(getFileContent(fileName));
290 | dispatch(openDialog(DIALOGS.EDIT));
291 | };
292 |
293 |
294 | /**
295 | * Request API to get file content and display it
296 | */
297 | export const loadAndDisplayFile = (fileName: string): MyThunk => (dispatch, getState) => {
298 | dispatch(getFileContent(fileName));
299 | dispatch(openDialog(DIALOGS.CONTENT));
300 | };
301 |
302 |
303 | /**
304 | * Request API to display an audio or video file
305 | */
306 | export const displaySelectedMediaFile = (): MyThunk => (dispatch, getState) => {
307 | dispatch(openDialog(DIALOGS.MEDIA));
308 | };
309 |
310 |
311 | /**
312 | * Request API to create a folder then dispatch defined events
313 | */
314 | export const createNewFolder = (folderName: string): MyThunk => (dispatch, getState) => {
315 | const { path } = getState();
316 | dispatch(displayLoading());
317 |
318 | APIHandler.createFolder(path.join('/'), folderName)
319 | .then(r => {
320 | dispatch(displayCurrentItemList());
321 | dispatch(closeDialog(DIALOGS.CREATE_FOLDER));
322 | })
323 | .catch(r => dispatch(setErrorMessage(String(r))))
324 | .finally(() => dispatch(stopLoading()));
325 | };
326 |
327 |
328 | /**
329 | * Request API to remove multiple items
330 | */
331 | export const removeItems = (items: Item[]): MyThunk => (dispatch, getState) => {
332 | const { path } = getState();
333 | dispatch(displayLoading());
334 |
335 | const itemNames = items.map(f => f.name);
336 |
337 | APIHandler.removeItems(path.join('/'), itemNames)
338 | .then(r => dispatch(displayCurrentItemList()))
339 | .catch(r => dispatch(setErrorMessage(String(r))))
340 | .finally(() => dispatch(stopLoading()));
341 | };
342 |
343 |
344 | /**
345 | * Request API to move multiple items
346 | */
347 | export const moveItems = (items: Item[], { host, path: targetPath }: { host: string, path: string[] }): MyThunk => (dispatch, getState) => {
348 | const { path } = getState();
349 | dispatch(displayLoading());
350 |
351 |
352 | const destination = targetPath.join('/');
353 | const itemNames = items.map(f => f.name);
354 |
355 | APIHandler.moveItems(path.join('/'), destination, itemNames)
356 | .then(r => {
357 | dispatch(displayCurrentItemList());
358 | dispatch(closeDialog(DIALOGS.MOVE));
359 | })
360 | .catch(r => dispatch(setErrorMessage(String(r))))
361 | .finally(() => dispatch(stopLoading()));
362 | };
363 |
364 |
365 | /**
366 | * Request API to copy an item then dispatch defined events
367 | */
368 | export const copyItems = (items: Item[], { host, path: targetPath }: { host: string, path: string[] }): MyThunk => (dispatch, getState) => {
369 | const { path } = getState();
370 | dispatch(displayLoading());
371 |
372 | const destination = targetPath.join('/');
373 | const itemNames = items.map(f => f.name);
374 |
375 | APIHandler.copyItems(path.join('/'), destination, itemNames)
376 | .then(r => {
377 | dispatch(displayCurrentItemList());
378 | dispatch(closeDialog(DIALOGS.COPY));
379 | })
380 | .catch(r => dispatch(setErrorMessage(String(r))))
381 | .finally(() => dispatch(stopLoading()));
382 | };
383 |
384 |
385 | /**
386 | * This handles multiple selection by using shift key
387 | */
388 | export const setSelectedItemsFromLastTo = (lastFile: Item): MyThunk => (dispatch, getState) => {
389 | const { items: { inCurFolder: items, selected: selectedItems } } = getState();
390 |
391 | const lastPreviouslySelected = [...selectedItems].pop();
392 | if (!lastPreviouslySelected)
393 | return dispatch(setErrorMessage("Couldn't enlarge selection because no items were previously selected"));
394 |
395 | const lastPreviouslySelectedIndex = items.indexOf(lastPreviouslySelected);
396 | const lastSelectedIndex = items.indexOf(lastFile);
397 |
398 | const isInRange = (num: number, start: number, end: number) => start <= num && num <= end;
399 | const toAdd = lastSelectedIndex > lastPreviouslySelectedIndex ?
400 | items.filter((item, index) => isInRange(index, lastPreviouslySelectedIndex, lastSelectedIndex))
401 | : items.filter((item, index) => isInRange(index, lastSelectedIndex, lastPreviouslySelectedIndex));
402 |
403 | dispatch(selectItems([...selectedItems, ...toAdd]));
404 | };
405 |
406 | export const resetFileUploader = (): MyThunk => (dispatch, getState) => {
407 | dispatch(setFileUploadProgress(0));
408 | dispatch(closeDialog(DIALOGS.UPLOAD_FILE));
409 | dispatch(resetFileUploadList());
410 | };
411 |
412 |
413 | export const enterFolder = (path: string[]): MyThunk => (dispatch, getState) => {
414 | dispatch(setPath(path));
415 | dispatch(resetFilter());
416 | dispatch(displayCurrentItemList());
417 | };
418 |
419 | export const enterFolderByItem = (item: Item): MyThunk => (dispatch, getState) => {
420 | const path = item.path;
421 | // Open containing folder if it is a file
422 | dispatch(enterFolder(item instanceof FileItem ? path : [...path, item.name]));
423 | };
424 |
425 | export const moveFolderUpwardsAndRefresh = (n: number): MyThunk => (dispatch, getState) => {
426 | dispatch(moveFolderUpwards(n));
427 | dispatch(refreshItemList());
428 | };
429 |
430 | export const rightClickOnFile = (item: Item): MyThunk => (dispatch, getState) => {
431 | const { items: { selected } } = getState();
432 | const isSelected = selected.includes(item);
433 |
434 | !isSelected && dispatch(selectItem(item));
435 | };
436 |
437 |
438 | // Create action which can be dispatched
439 | const makeActionCreator: (type: string) => (value: VALUE) => Action = (type: string) => (value: VALUE) => {
440 | return {
441 | type,
442 | value
443 | };
444 | };
445 |
446 | export const moveFolderUpwards = makeActionCreator(MOVE_FOLDER_UPWARDS);
447 | export const setPath = makeActionCreator(SET_PATH);
448 | export const resetPath = () => setPath([]);
449 |
450 | export const setLoggedIn = makeActionCreator(SET_LOGGED_IN);
451 | export const setLoggedOut = makeActionCreator(SET_LOGGED_OUT);
452 | export const setHost = makeActionCreator(SET_HOST);
453 | export const resetHost = makeActionCreator(RESET_HOST);
454 | export const setWebId = makeActionCreator(SET_WEB_ID);
455 | export const resetWebId = makeActionCreator(RESET_WEB_ID);
456 |
457 | export const toggleWithAcl = makeActionCreator(TOGGLE_WITH_ACL);
458 | export const toggleWithMeta = makeActionCreator(TOGGLE_WITH_META);
459 |
460 | export const setItems = makeActionCreator- (SET_ITEMS);
461 | export const resetItems = () => setItems([]);
462 |
463 | export const selectItems = makeActionCreator
- (SELECT_ITEMS);
464 | export const selectItem = (item: Item) => selectItems([item]);
465 | export const resetSelectedItems = () => selectItems([]);
466 | export const toggleSelectedItem = makeActionCreator
- (TOGGLE_SELECTED_ITEM);
467 | export const deselectItem = makeActionCreator
- (DESELECT_ITEM);
468 |
469 | export const filterItems = makeActionCreator
(FILTER_ITEMS);
470 | export const resetFilter = makeActionCreator(RESET_FILTER);
471 |
472 |
473 | export const displayLoading = makeActionCreator(DISPLAY_LOADING);
474 | export const stopLoading = makeActionCreator(STOP_LOADING);
475 |
476 | export const resetFileContent = makeActionCreator(RESET_LOADED_BLOB);
477 | export const setFileContent = makeActionCreator(SET_LOADED_BLOB);
478 | export const setFileUploadList = makeActionCreator(SET_UPLOAD_FILE_LIST);
479 | export const resetFileUploadList = () => setFileUploadList(null);
480 | export const setFileUploadProgress = makeActionCreator(SET_UPLOAD_FILE_PROGRESS);
481 |
482 | export const openDialog = makeActionCreator(OPEN_DIALOG);
483 | export const closeDialog = makeActionCreator(CLOSE_DIALOG);
484 | export const openContextMenu = makeActionCreator<{ x: number, y: number }>(OPEN_CONTEXT_MENU);
485 | export const closeContextMenu = makeActionCreator(CLOSE_CONTEXT_MENU);
486 |
487 | export const setErrorMessage = makeActionCreator(SET_ERROR_MESSAGE);
488 | export const resetErrorMessage = () => setErrorMessage('');
489 |
--------------------------------------------------------------------------------