├── .env ├── .gitignore ├── LICENSE.md ├── README.md ├── etc ├── logo.ai └── logo.png ├── package-lock.json ├── package.json ├── public ├── favicon.png ├── index.html ├── locales │ └── en │ │ ├── referenced-by-list.json │ │ └── translation.json ├── logo192.png ├── logo512.png ├── manifest.json ├── popup.html └── robots.txt ├── src ├── @solid │ ├── query-ldflex │ │ └── index.d.ts │ └── solid__react │ │ └── index.d.ts ├── App.css ├── App.js ├── App.test.js ├── AppErrorBoundary.tsx ├── base32 │ └── index.d.ts ├── components │ ├── AppTitle.js │ ├── Backups.tsx │ ├── BackupsDialog.js │ ├── ChecklistItemElement.js │ ├── ConceptCreator.tsx │ ├── Console.js │ ├── CurrentConcept.tsx │ ├── CurrentPage.tsx │ ├── Document.tsx │ ├── DocumentTextEditor.tsx │ ├── Editable.tsx │ ├── EditorToolbar.js │ ├── EmbedElement.js │ ├── EmbedPicker.js │ ├── FAQ.js │ ├── Home.js │ ├── IconButton.tsx │ ├── ImageUploader.js │ ├── LandingPage.tsx │ ├── Link.tsx │ ├── Loader.tsx │ ├── LogInLogOutButton.js │ ├── ProfileLink.tsx │ ├── PublicProfile.tsx │ ├── ReferencedByList.tsx │ ├── ReportErrorButton.tsx │ ├── SharingModal.js │ ├── SignupPage.js │ ├── SocialIcons.js │ ├── UnrecoverableErrorPage.tsx │ ├── WhatPage.js │ ├── Workspace.js │ ├── WorkspaceDrawer.tsx │ └── edit │ │ ├── Block.tsx │ │ ├── ConceptElement.tsx │ │ ├── ImageElement.tsx │ │ ├── InsertMenu.tsx │ │ ├── LinkElement.tsx │ │ ├── Table.tsx │ │ ├── TurnIntoMenu.tsx │ │ └── index.ts ├── constants.js ├── context │ ├── auth.tsx │ ├── document.ts │ ├── preferences.tsx │ └── workspace.tsx ├── hooks │ ├── acls.ts │ ├── backup.ts │ ├── concepts.ts │ ├── data.ts │ └── pages.ts ├── i18n.js ├── index.css ├── index.js ├── logo.svg ├── ontology.ts ├── react-app-env.d.ts ├── serviceWorker.js ├── setupTests.js ├── solid-namespace │ └── index.d.ts ├── theme.js ├── utils │ ├── acl.ts │ ├── backups.ts │ ├── data.ts │ ├── editor.js │ ├── fonts.js │ ├── ldflex-helper.js │ ├── model.ts │ ├── slate.ts │ └── urls.ts └── wac-allow │ └── index.d.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | -------------------------------------------------------------------------------- /.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 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Travis Vachon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | * No Harm: The software may not be used by anyone for systems or activities that actively and knowingly endanger, harm, or otherwise threaten the physical, mental, economic, or general well-being of other individuals or groups, in violation of the United Nations Universal Declaration of Human Rights (https://www.un.org/en/universal-declaration-human-rights/). 8 | 9 | * Services: If the Software is used to provide a service to others, the licensee shall, as a condition of use, require those others not to use the service in any way that violates the No Harm clause above. 10 | 11 | * Enforceability: If any portion or provision of this License shall to any extent be declared illegal or unenforceable by a court of competent jurisdiction, then the remainder of this License, or the application of such portion or provision in circumstances other than those as to which it is so declared illegal or unenforceable, shall not be affected thereby, and each portion and provision of this Agreement shall be valid and enforceable to the fullest extent permitted by law. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | 15 | This Hippocratic License is an Ethical Source license (https://ethicalsource.dev) derived from the MIT License, amended to limit the impact of the unethical use of open source software. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Thanks for your interest in Concept! Unfortunately it is currently 2 | unsupported and I have no plans to continue developing it. Happily, it 3 | has been repurposed and reconfigured and reborn as 4 | [itme.online](https://itme.online), a Grant for the Web Flagship 5 | Project. Follow that repository or subscribe to [our 6 | blog](http://itme.press/) or [newsletter](https://tinyletter.com/itme) 7 | to learn more about cutting edge Solid web development. 8 | 9 | ~ tfv 1/25/21 10 | 11 | # Concept 12 | 13 | All (well, some)-in-one workspace built on [Solid](https://solidproject.org/). 14 | 15 | Use Concept: 16 | 17 | [useconcept.art](http://useconcept.art) 18 | 19 | # Development 20 | 21 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 22 | 23 | ## Available Scripts 24 | 25 | In the project directory, you can run: 26 | 27 | ### `npm start` 28 | 29 | Runs the app in the development mode.
30 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 31 | 32 | The page will reload if you make edits.
33 | You will also see any lint errors in the console. 34 | 35 | ### `npm test` 36 | 37 | Launches the test runner in the interactive watch mode.
38 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 39 | 40 | ### `npm run build` 41 | 42 | Builds the app for production to the `build` folder.
43 | It correctly bundles React in production mode and optimizes the build for the best performance. 44 | 45 | The build is minified and the filenames include the hashes.
46 | Your app is ready to be deployed! 47 | 48 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 49 | 50 | ### `npm run eject` 51 | 52 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 53 | 54 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 55 | 56 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 57 | 58 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 59 | 60 | ## Learn More 61 | 62 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 63 | 64 | To learn React, check out the [React documentation](https://reactjs.org/). 65 | 66 | ### Code Splitting 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 69 | 70 | ### Analyzing the Bundle Size 71 | 72 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 73 | 74 | ### Making a Progressive Web App 75 | 76 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 77 | 78 | ### Advanced Configuration 79 | 80 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 81 | 82 | ### Deployment 83 | 84 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 85 | 86 | ### `npm run build` fails to minify 87 | 88 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 89 | -------------------------------------------------------------------------------- /etc/logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/travis/concept/c7a27fd8a441c98657fecab96f1cf037cb8a2309/etc/logo.ai -------------------------------------------------------------------------------- /etc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/travis/concept/c7a27fd8a441c98657fecab96f1cf037cb8a2309/etc/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "concept", 3 | "version": "0.1.0-dev.0", 4 | "private": true, 5 | "dependencies": { 6 | "@inrupt/solid-react-components": "^0.5.1-dev.19", 7 | "@material-ui/core": "^4.9.5", 8 | "@material-ui/icons": "^4.9.1", 9 | "@material-ui/lab": "^4.0.0-alpha.45", 10 | "@rdfjs/data-model": "^1.1.2", 11 | "@sentry/browser": "^5.15.5", 12 | "@solid/query-ldflex": "^2.9.0", 13 | "@solid/react": "^1.8.0", 14 | "@testing-library/jest-dom": "^4.2.4", 15 | "@testing-library/react": "^9.4.0", 16 | "@testing-library/user-event": "^7.2.1", 17 | "@types/react-loader-spinner": "^3.1.0", 18 | "@types/react-router-dom": "^5.1.3", 19 | "@types/solid-auth-client": "^2.4.0", 20 | "@types/uuid": "^7.0.2", 21 | "base32": "0.0.6", 22 | "copy-to-clipboard": "^3.3.1", 23 | "i18next": "^19.4.0", 24 | "i18next-browser-languagedetector": "^4.0.2", 25 | "i18next-xhr-backend": "^3.2.2", 26 | "image-extensions": "^1.1.0", 27 | "is-hotkey": "^0.1.6", 28 | "is-url": "^1.2.4", 29 | "notistack": "^0.9.11", 30 | "parse-link-header": "^1.0.1", 31 | "rdf-namespaces": "^1.8.0", 32 | "react": "^16.12.0", 33 | "react-cropper": "^1.3.0", 34 | "react-dnd": "^10.0.2", 35 | "react-dnd-html5-backend": "^10.0.2", 36 | "react-dom": "^16.12.0", 37 | "react-i18next": "^11.3.4", 38 | "react-loader-spinner": "^3.1.5", 39 | "react-router-dom": "^5.1.2", 40 | "react-scripts": "3.3.0", 41 | "react-slate": "^0.5.1", 42 | "slate": "^0.57.1", 43 | "slate-history": "^0.57.1", 44 | "slate-react": "^0.57.1", 45 | "solid-auth-client": "^2.4.1", 46 | "solid-namespace": "^0.3.0", 47 | "styled-components": "^5.0.1", 48 | "typescript": "^3.8.3", 49 | "use-debounce": "^3.3.0", 50 | "uuid": "^3.4.0", 51 | "wac-allow": "^1.0.0" 52 | }, 53 | "scripts": { 54 | "start": "react-scripts start", 55 | "build": "react-scripts build", 56 | "test": "react-scripts test", 57 | "eject": "react-scripts eject" 58 | }, 59 | "eslintConfig": { 60 | "extends": "react-app" 61 | }, 62 | "browserslist": { 63 | "production": [ 64 | ">0.2%", 65 | "not dead", 66 | "not op_mini all" 67 | ], 68 | "development": [ 69 | "last 1 chrome version", 70 | "last 1 firefox version", 71 | "last 1 safari version" 72 | ] 73 | }, 74 | "devDependencies": { 75 | "@sentry/webpack-plugin": "^1.11.1" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/travis/concept/c7a27fd8a441c98657fecab96f1cf037cb8a2309/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 15 | 16 | 17 | 26 | 27 | 28 | Concept 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/locales/en/referenced-by-list.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "{{count}} reference to {{name}}", 3 | "title_plural": "{{count}} references to {{name}}" 4 | } 5 | -------------------------------------------------------------------------------- /public/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages": "pages", 3 | "concepts": "concepts", 4 | "referencedByList": { 5 | "title": "{{count}} reference to {{name}}", 6 | "title_plural": "{{count}} references to {{name}}" 7 | }, 8 | "editable": { 9 | "placeholder": "Click here to edit..." 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/travis/concept/c7a27fd8a441c98657fecab96f1cf037cb8a2309/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/travis/concept/c7a27fd8a441c98657fecab96f1cf037cb8a2309/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/@solid/query-ldflex/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@solid/query-ldflex'; 2 | -------------------------------------------------------------------------------- /src/@solid/solid__react/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@solid/react'; 2 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | html, body, #root, .App { 2 | height: 100% 3 | } 4 | 5 | .App { 6 | text-align: center; 7 | } 8 | 9 | .App-logo { 10 | height: 40vmin; 11 | pointer-events: none; 12 | } 13 | 14 | @media (prefers-reduced-motion: no-preference) { 15 | .App-logo { 16 | animation: App-logo-spin infinite 20s linear; 17 | } 18 | } 19 | 20 | .App-header { 21 | background-color: #282c34; 22 | min-height: 100vh; 23 | display: flex; 24 | flex-direction: column; 25 | align-items: center; 26 | justify-content: center; 27 | font-size: calc(10px + 2vmin); 28 | color: white; 29 | } 30 | 31 | .App-link { 32 | color: #61dafb; 33 | } 34 | 35 | @keyframes App-logo-spin { 36 | from { 37 | transform: rotate(0deg); 38 | } 39 | to { 40 | transform: rotate(360deg); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; 3 | import CssBaseline from '@material-ui/core/CssBaseline'; 4 | import { DndProvider } from 'react-dnd' 5 | import DndBackend from 'react-dnd-html5-backend' 6 | import {useLoggedIn} from '@solid/react'; 7 | import {SnackbarProvider} from 'notistack'; 8 | import { ThemeProvider } from '@material-ui/core/styles'; 9 | 10 | import './App.css'; 11 | import {AuthProvider} from './context/auth' 12 | import {PreferencesProvider} from './context/preferences' 13 | 14 | import Workspace from './components/Workspace'; 15 | import Console from './components/Console'; 16 | import LandingPage from './components/LandingPage'; 17 | import SignupPage from './components/SignupPage'; 18 | import WhatPage from './components/WhatPage'; 19 | import CurrentPage from "./components/CurrentPage" 20 | import CurrentConcept from "./components/CurrentConcept" 21 | import Loader from "./components/Loader" 22 | import PublicProfile, {EncodedWebIdPublicProfile} from './components/PublicProfile'; 23 | import AppErrorBoundary from "./AppErrorBoundary"; 24 | import theme from './theme' 25 | 26 | function App() { 27 | const loggedIn = useLoggedIn() 28 | return ( 29 |
30 | 31 | 32 | {(loggedIn === undefined) ? ( 33 | 34 | ) : ( 35 | loggedIn ? ( 36 | 37 | 38 | 39 | 40 | ) : ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ))} 50 | 51 |
52 | ) 53 | } 54 | 55 | function AppContainer() { 56 | return ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ); 74 | } 75 | 76 | export default AppContainer; 77 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/AppErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import * as Sentry from '@sentry/browser'; 3 | import Button from '@material-ui/core/Button'; 4 | import { withSnackbar, ProviderContext } from 'notistack'; 5 | 6 | import UnrecoverableErrorPage from './components/UnrecoverableErrorPage'; 7 | 8 | type AppErrorBoundaryProps = { children: ReactNode } & ProviderContext 9 | type AppErrorBoundaryState = { 10 | hasError: boolean, eventId: string | undefined, error: any, 11 | snackbarOpen: boolean, lastErrorTime: number | null, 12 | possibleInfiniteLoop: boolean 13 | } 14 | 15 | class AppErrorBoundary extends React.Component { 16 | state = { 17 | eventId: undefined, error: null, hasError: false, 18 | snackbarOpen: false, lastErrorTime: null, 19 | possibleInfiniteLoop: false 20 | } 21 | 22 | static getDerivedStateFromError(error: any) { 23 | // Update state so the next render will show the fallback UI. 24 | return { 25 | hasError: true, error 26 | } 27 | }; 28 | 29 | componentDidCatch(error: any, errorInfo: any) { 30 | Sentry.withScope((scope) => { 31 | scope.setExtras(errorInfo); 32 | const eventId = Sentry.captureException(error); 33 | const now = Date.now() 34 | const lastErrorTime = this.state.lastErrorTime 35 | const possibleInfiniteLoop = (!!lastErrorTime && ((now - lastErrorTime) < 500)) 36 | this.setState({ 37 | eventId, lastErrorTime: now, 38 | possibleInfiniteLoop 39 | }); 40 | this.props.enqueueSnackbar("Oh no - we caught an error!", { 41 | variant: "error", 42 | action: 43 | }) 44 | }); 45 | } 46 | 47 | handleClose() { 48 | this.setState({ hasError: false, snackbarOpen: false }) 49 | } 50 | 51 | render() { 52 | if (this.state.possibleInfiniteLoop) { 53 | return 54 | } else { 55 | return this.props.children 56 | } 57 | } 58 | } 59 | 60 | export default withSnackbar(AppErrorBoundary) 61 | -------------------------------------------------------------------------------- /src/base32/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'base32'; 2 | -------------------------------------------------------------------------------- /src/components/AppTitle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Link} from 'react-router-dom'; 4 | 5 | import Grid from '@material-ui/core/Grid'; 6 | import Typography from '@material-ui/core/Typography'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | 9 | import { titleFont } from '../utils/fonts' 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | name: { 13 | marginTop: theme.spacing(3), 14 | marginBottom: theme.spacing(-3), 15 | fontFamily: titleFont 16 | }, 17 | nameLink: { 18 | textDecoration: "none", 19 | color: "inherit" 20 | }, 21 | version: { 22 | position: "relative", 23 | left: theme.spacing(12), 24 | height: 0 25 | }, 26 | motto: { 27 | marginBottom: theme.spacing(1), 28 | } 29 | })) 30 | 31 | export function AppTitleGridRow(){ 32 | const classes = useStyles({}) 33 | return ( 34 | 35 | 36 | 37 | 38 | Concept 39 | 40 | 41 | 42 | alpha 43 | 44 | 45 | 46 | ) 47 | } 48 | 49 | export function MottoGridRow(){ 50 | const classes = useStyles({}) 51 | return ( 52 | 53 | 54 | 55 | Concept is a collaborative workspace for organizing the world. 56 | 57 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Backups.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, PropsWithChildren } from 'react'; 2 | 3 | import data from '@solid/query-ldflex'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Button from '@material-ui/core/Button'; 7 | import Paper from '@material-ui/core/Paper'; 8 | import Dialog from '@material-ui/core/Dialog'; 9 | import DialogActions from '@material-ui/core/DialogActions'; 10 | import DialogContent from '@material-ui/core/DialogContent'; 11 | import DialogContentText from '@material-ui/core/DialogContentText'; 12 | import DialogTitle from '@material-ui/core/DialogTitle'; 13 | import Table from '@material-ui/core/Table'; 14 | import TableBody from '@material-ui/core/TableBody'; 15 | import TableCell from '@material-ui/core/TableCell'; 16 | import TableContainer from '@material-ui/core/TableContainer'; 17 | import TableHead from '@material-ui/core/TableHead'; 18 | import TableRow from '@material-ui/core/TableRow'; 19 | import IconButton from '@material-ui/core/IconButton'; 20 | import CloseIcon from '@material-ui/icons/Close'; 21 | 22 | import { ldp, schema, dct } from 'rdf-namespaces'; 23 | 24 | import { Slate } from 'slate-react'; 25 | 26 | import { useListValuesQuery, useValueQuery, useDateQuery } from '../hooks/data'; 27 | import { backupFolderForPage } from '../utils/backups' 28 | import { Document } from '../utils/model' 29 | import { createBackup } from '../hooks/backup' 30 | import concept from '../ontology' 31 | import Editable, { useNewEditor } from "./Editable"; 32 | import Loader from "./Loader"; 33 | 34 | const useStyles = makeStyles(theme => ({ 35 | editor: { 36 | textAlign: "left", 37 | padding: theme.spacing(2), 38 | background: "white", 39 | position: "relative", 40 | height: "600em", 41 | minWidth: theme.spacing(100) 42 | }, 43 | table: { 44 | mindWidth: 650 45 | }, 46 | restoreLoader: { 47 | textAlign: "right", 48 | paddingRight: theme.spacing(4) 49 | }, 50 | previewCloseButton: { 51 | position: 'absolute', 52 | right: theme.spacing(1), 53 | top: theme.spacing(1), 54 | color: theme.palette.grey[500], 55 | }, 56 | })); 57 | 58 | type CloseOpts = { closeAll: boolean } 59 | type HandleClose = (opts?: CloseOpts) => void 60 | type RestoreDialogProps = { original: string, date: Date, restore: () => void, handleClose: HandleClose, open: boolean } 61 | 62 | function RestoreDialog({ original, date, restore, handleClose, open }: RestoreDialogProps) { 63 | const [originalName] = useValueQuery(original, schema.name) 64 | const [restoring, setRestoring] = useState(false) 65 | const handleConfirm = useCallback(async () => { 66 | setRestoring(true) 67 | await restore() 68 | handleClose({ closeAll: true }) 69 | }, [restore, handleClose]) 70 | const classes = useStyles() 71 | return ( 72 | handleClose()} 75 | aria-labelledby="confirm-restore-dialog-title" 76 | aria-describedby="confirm-restore-dialog-description" 77 | > 78 | 79 | Restore {date.toLocaleString()} version of {originalName}? 80 | 81 | 82 | 83 | We'll create a backup of the current version first. 84 | 85 | 86 | {restoring ? ( 87 | 88 | ) : ( 89 | 90 | 93 | 96 | 97 | )} 98 | 99 | ) 100 | } 101 | 102 | function PreviewName({ original }: { original: string }) { 103 | const [name] = useValueQuery(original, schema.name) 104 | return <>{name} 105 | } 106 | 107 | type PreviewDialogProps = { backup: string, date: Date, open?: boolean, handleClose: HandleClose } 108 | 109 | function PreviewDialog({ backup, date, open = true, handleClose }: PreviewDialogProps) { 110 | const [showRestore, setShowRestore] = useState(false) 111 | const [original] = useValueQuery(backup, concept.backupOf) 112 | const [backupText] = useValueQuery(backup, schema.text) 113 | const editor = useNewEditor() 114 | const classes = useStyles(); 115 | const restore = useCallback(async () => { 116 | const currentText = data[original][schema.text] 117 | await createBackup(original, "beforeLastRestore.ttl", (await currentText).value) 118 | await currentText.set(backupText) 119 | setShowRestore(false) 120 | handleClose({ closeAll: true }) 121 | }, [original, backupText, handleClose]) 122 | return ( 123 | handleClose()}> 124 | 125 | {original && } 126 | handleClose()}> 127 | 128 | 129 | 130 | 131 | {backupText && ( 132 | { }}> 133 | 134 | 135 | 136 | 137 | )} 138 | 139 | 140 | 143 | 144 | { 145 | showRestore && { 147 | setShowRestore(false) 148 | handleClose(opts) 149 | }} 150 | open={showRestore} /> 151 | } 152 | 153 | ) 154 | } 155 | 156 | type BackupProps = { 157 | backupFolder: string, 158 | backup: string, 159 | handleClose: HandleClose 160 | } 161 | 162 | function Backup({ backupFolder, backup, handleClose }: PropsWithChildren) { 163 | const [showPreview, setShowPreview] = useState(false) 164 | const meta = `${backupFolder}.meta` 165 | const [backupDate] = useDateQuery(backup, dct.modified, { source: meta }) 166 | const handleClosePreview = useCallback(({ closeAll } = { closeAll: false }) => { 167 | setShowPreview(false) 168 | if (closeAll) { 169 | handleClose({ closeAll }) 170 | } 171 | }, [handleClose]) 172 | return ( 173 | 174 | {backupDate && backupDate.toLocaleString()} 175 | {backup && backup.split("/").slice(-1)[0]} 176 | 177 | 178 | {showPreview && handleClosePreview(opts)} />} 180 | 181 | 182 | ) 183 | } 184 | 185 | type BackupsProps = { 186 | document: Document, 187 | handleClose: HandleClose 188 | } 189 | 190 | export default function Backups({ document, handleClose }: BackupsProps) { 191 | const backupFolder = backupFolderForPage(document.uri) 192 | const [backups] = useListValuesQuery(backupFolder, ldp.contains) 193 | const classes = useStyles() 194 | return ( 195 | 196 | 197 | 198 | 199 | Date 200 | Name 201 | 202 | 203 | 204 | 205 | { 206 | backups && backups.map((backup: string) => ( 207 | 208 | {backup} 209 | 210 | )) 211 | } 212 | 213 |
214 |
215 | ) 216 | } 217 | -------------------------------------------------------------------------------- /src/components/BackupsDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Dialog from '@material-ui/core/Dialog'; 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 IconButton from '@material-ui/core/IconButton'; 9 | import CloseIcon from '@material-ui/icons/Close'; 10 | 11 | import { schema } from 'rdf-namespaces'; 12 | 13 | import { useValueQuery } from '../hooks/data'; 14 | import Backups from './Backups'; 15 | 16 | const useStyles = makeStyles(theme => ({ 17 | closeButton: { 18 | position: 'absolute', 19 | right: theme.spacing(1), 20 | top: theme.spacing(1), 21 | color: theme.palette.grey[500], 22 | } 23 | })); 24 | 25 | export default function BackupsDialog({document, open, handleClose}) { 26 | const [name] = useValueQuery(document.uri, schema.name); 27 | const classes = useStyles(); 28 | return ( 29 | 30 | 31 | Backups 32 | 33 | 34 | 35 | 36 | 37 | 38 | Backups of {name} 39 | 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/ChecklistItemElement.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | useEditor, useReadOnly, ReactEditor 5 | } from 'slate-react'; 6 | import { Transforms } from 'slate'; 7 | import Checkbox from '@material-ui/core/Checkbox'; 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | container: { 12 | display: "flex", 13 | flexDirection: "row", 14 | alignItems: "center", 15 | "& + &": { 16 | marginTop: 0 17 | } 18 | }, 19 | checkbox: { 20 | padding: theme.spacing(0.5), 21 | marginRight: theme.spacing(0.5) 22 | }, 23 | text: { 24 | flex: 1, 25 | opacity: ({checked}) => checked ? 0.666 : 1, 26 | textDecoration: ({checked}) => checked ? 'line-through' : 'none', 27 | "&:focus": { 28 | outline: "none" 29 | } 30 | } 31 | })) 32 | 33 | export default function CheckListItemElement({ attributes, children, element }) { 34 | const editor = useEditor() 35 | const readOnly = useReadOnly() 36 | const { checked } = element 37 | const classes = useStyles({checked}) 38 | return ( 39 |
43 | { 50 | const path = ReactEditor.findPath(editor, element) 51 | Transforms.setNodes( 52 | editor, 53 | { checked: event.target.checked }, 54 | { at: path } 55 | ) 56 | }} 57 | /> 58 | 63 | {children} 64 | 65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/components/ConceptCreator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useState, FunctionComponent } from 'react' 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Button from '@material-ui/core/Button'; 5 | import Dialog, { DialogProps } from '@material-ui/core/Dialog'; 6 | import DialogContent from '@material-ui/core/DialogContent'; 7 | import DialogContentText from '@material-ui/core/DialogContentText'; 8 | import DialogActions from '@material-ui/core/DialogActions'; 9 | import TextField from '@material-ui/core/TextField'; 10 | 11 | import { useDebounce } from 'use-debounce'; 12 | 13 | import Loader from './Loader'; 14 | import WorkspaceContext from "../context/workspace"; 15 | import { resourceExists } from '../utils/ldflex-helper' 16 | import { conceptUri } from '../utils/urls' 17 | 18 | const useStyles = makeStyles(theme => ({ 19 | nameTextField: { 20 | width: "100%", 21 | marginTop: theme.spacing(2), 22 | } 23 | })) 24 | 25 | type ConceptCreatorProps = DialogProps & { 26 | close: () => void 27 | } 28 | 29 | const ConceptCreator: FunctionComponent = ({ close, ...props }) => { 30 | const classes = useStyles() 31 | const { workspace, addConcept } = useContext(WorkspaceContext); 32 | const [saving, setSaving] = useState(false) 33 | const [nameCheckNeeded, setNameCheckNeeded] = useState(false) 34 | const [conceptExists, setConceptExists] = useState() 35 | const [error, setError] = useState(null) 36 | const [name, setName] = useState("") 37 | const [debouncedName] = useDebounce(name, 500); 38 | 39 | const save = async () => { 40 | setError(null) 41 | setSaving(true) 42 | addConcept && await addConcept({ name: debouncedName }) 43 | setSaving(false) 44 | close() 45 | } 46 | useEffect(() => { 47 | const checkName = async () => { 48 | if (workspace && (debouncedName !== "")) { 49 | setConceptExists(await resourceExists(conceptUri(workspace.conceptContainerUri, debouncedName))) 50 | } 51 | } 52 | if (nameCheckNeeded) { 53 | setNameCheckNeeded(false) 54 | checkName() 55 | } 56 | }, [debouncedName, nameCheckNeeded, workspace]) 57 | useEffect(() => setNameCheckNeeded(true), [debouncedName]) 58 | return ( 59 | e.stopPropagation()}> 60 | 61 | {error && ( 62 | 63 | {error} 64 | 65 | )} 66 | {(conceptExists === true) && ( 67 | 68 | {debouncedName} already exists 69 | 70 | )} 71 | setName(e.target.value)} /> 75 | 76 | 77 | {saving ? ( 78 | 79 | ) : ( 80 | <> 81 | 84 | 87 | 88 | )} 89 | 90 | 91 | ) 92 | } 93 | 94 | export default ConceptCreator 95 | -------------------------------------------------------------------------------- /src/components/Console.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { LiveUpdate } from "@solid/react"; 3 | 4 | import Link from '@material-ui/core/Link'; 5 | import Button from '@material-ui/core/Button'; 6 | 7 | import { addPage } from "../utils/model" 8 | import { useWorkspace, usePageListItems } from "../hooks/data" 9 | 10 | function Workspace({workspace}){ 11 | const [pageListItems] = usePageListItems(workspace) 12 | return ( 13 |
14 | {workspace && {workspace.uri}} 15 | 16 | {pageListItems && pageListItems.map(item =>
{item.pageUri}
)} 17 |
18 | ) 19 | } 20 | 21 | export default function Console(){ 22 | const workspace = useWorkspace() 23 | return ( 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/CurrentConcept.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Box from '@material-ui/core/Box'; 5 | 6 | import { LiveUpdate } from "@solid/react"; 7 | import { useCurrentConceptUri, useCurrentConcept } from '../hooks/concepts'; 8 | import Page from './Document' 9 | import Loader from './Loader' 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | content: { 13 | flexGrow: 1, 14 | position: "relative", 15 | height: "100%" 16 | }, 17 | })); 18 | 19 | function ConceptInsideLiveUpdate() { 20 | const [currentConcept] = useCurrentConcept() 21 | return currentConcept ? () : () 22 | } 23 | 24 | export default function CurrentConcept() { 25 | const classes = useStyles() 26 | const currentConceptUri = useCurrentConceptUri() 27 | return ( 28 | 29 | { 30 | currentConceptUri ? ( 31 | 32 | 33 | 34 | ) : ( 35 | 36 | ) 37 | } 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/CurrentPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Box from '@material-ui/core/Box'; 5 | 6 | import { LiveUpdate } from "@solid/react"; 7 | import { useCurrentPageUri, useCurrentPage } from '../hooks/pages'; 8 | import Page from './Document' 9 | import Loader from './Loader' 10 | 11 | const usePagesStyles = makeStyles(theme => ({ 12 | content: { 13 | flexGrow: 1, 14 | position: "relative", 15 | height: "100%" 16 | }, 17 | })); 18 | 19 | function PageInsideLiveUpdate() { 20 | const [currentPage] = useCurrentPage() 21 | return currentPage ? () : () 22 | } 23 | 24 | export default function CurrentPage() { 25 | const classes = usePagesStyles() 26 | const currentPageUri = useCurrentPageUri() 27 | return ( 28 | 29 | { 30 | currentPageUri ? ( 31 | 32 | 33 | 34 | ) : ( 35 | 36 | ) 37 | } 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Document.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useEffect, useRef, ReactNode, FunctionComponent } from 'react' 2 | 3 | import { LiveUpdate } from "@solid/react"; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import IconButton from './IconButton'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import AppBar from '@material-ui/core/AppBar'; 10 | import Toolbar from '@material-ui/core/Toolbar'; 11 | import Menu, { MenuProps } from '@material-ui/core/Menu'; 12 | import MenuItem from '@material-ui/core/MenuItem'; 13 | import Dialog from '@material-ui/core/Dialog'; 14 | import DialogTitle from '@material-ui/core/DialogTitle'; 15 | import DialogActions from '@material-ui/core/DialogActions'; 16 | import Button from '@material-ui/core/Button'; 17 | import Box from '@material-ui/core/Box'; 18 | import Paper from '@material-ui/core/Paper'; 19 | 20 | import ShareIcon from '@material-ui/icons/Share' 21 | import BackupIcon from '@material-ui/icons/Backup' 22 | import MenuIcon from '@material-ui/icons/Menu' 23 | import DeveloperModeIcon from '@material-ui/icons/DeveloperMode' 24 | 25 | import { useHistory } from "react-router-dom"; 26 | 27 | import Link from './Link'; 28 | import SharingModal from "./SharingModal"; 29 | import BackupsDialog from "./BackupsDialog"; 30 | import Loader from "./Loader"; 31 | 32 | import WorkspaceContext from "../context/workspace"; 33 | import DocumentContext from '../context/document' 34 | 35 | import DocumentTextEditor from './DocumentTextEditor' 36 | import ReferencedByList from './ReferencedByList' 37 | import { useAccessInfo } from '../hooks/acls'; 38 | import { drawerWidth } from '../constants' 39 | import { Page, Document, isConcept, isPage } from '../utils/model' 40 | import { usePreferences } from '../context/preferences' 41 | 42 | const useStyles = makeStyles(theme => ({ 43 | appBar: { 44 | width: ({ hasWorkspace }: any) => hasWorkspace ? `calc(100% - ${drawerWidth}px)` : "100%", 45 | marginLeft: ({ hasWorkspace }: any) => hasWorkspace ? drawerWidth : 0, 46 | background: "white" 47 | }, 48 | shareButton: { 49 | float: "right" 50 | }, 51 | grow: { 52 | flexGrow: 1, 53 | }, 54 | sectionDesktop: { 55 | display: 'flex', 56 | }, 57 | referencedBy: { 58 | flexGrow: 2 59 | }, 60 | error: { 61 | marginTop: theme.spacing(6) 62 | } 63 | })); 64 | 65 | type PageNameProps = { 66 | page: Page 67 | } 68 | 69 | function PageName({ page }: PageNameProps) { 70 | const { updateName } = useContext(WorkspaceContext); 71 | const [editing, setEditing] = useState(false); 72 | const [name, setName] = useState(page.name); 73 | useEffect(() => { 74 | page.name && setName(page.name); 75 | }, [page.name, page.uri]) 76 | 77 | const saveAndStopEditing = async () => { 78 | setEditing(false) 79 | if (updateName) { 80 | await updateName(page, name) 81 | } 82 | } 83 | 84 | return editing ? ( 85 | (e.key === 'Enter') && saveAndStopEditing()} 88 | onBlur={() => saveAndStopEditing()} 89 | onChange={(e) => setName(e.target.value)} /> 90 | ) : ( 91 | setEditing(true)} noWrap>{name} 92 | ); 93 | } 94 | 95 | type DocumentNameProps = { 96 | document: Document 97 | } 98 | 99 | function DocumentName({ document }: DocumentNameProps) { 100 | return ( 101 | {document.name} 102 | ); 103 | } 104 | 105 | type PE = FunctionComponent<{ 106 | error: any 107 | }> 108 | 109 | const PageError: PE = ({ error }) => { 110 | const classes = useStyles() 111 | console.log("showing error ui for ", error) 112 | return (
Sorry, something went wrong.
) 113 | 114 | } 115 | 116 | type EditorErrorBoundaryProps = { children: ReactNode } 117 | type EditorErrorBoundaryState = { hasError: boolean, error: any } 118 | 119 | class EditorErrorBoundary extends React.Component { 120 | constructor(props: EditorErrorBoundaryProps) { 121 | super(props); 122 | this.state = { hasError: false, error: null }; 123 | } 124 | 125 | static getDerivedStateFromError(error: any) { 126 | // Update state so the next render will show the fallback UI. 127 | return { hasError: true, error }; 128 | } 129 | 130 | componentDidCatch(error: any, errorInfo: any) { 131 | // You can also log the error to an error reporting service 132 | console.log("error rendering editable", error, errorInfo); 133 | } 134 | 135 | render() { 136 | if (this.state.hasError) { 137 | return () 138 | } else { 139 | return this.props.children; 140 | } 141 | } 142 | } 143 | 144 | interface AppBarMenuProps extends MenuProps { 145 | open: boolean, 146 | document: Document, 147 | onClose: () => void 148 | } 149 | 150 | function AppBarMenu({ document, onClose, ...props }: AppBarMenuProps) { 151 | const history = useHistory(); 152 | const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false) 153 | const { deleteDocument } = useContext(WorkspaceContext); 154 | const close = () => { 155 | setDeleteConfirmationOpen(false) 156 | onClose() 157 | } 158 | return ( 159 | <> 160 | 161 | 162 | 163 | Source 164 | 165 | 166 | setDeleteConfirmationOpen(true)}>Delete 167 | 168 | 169 | Are you sure you want to delete this page? 170 | 171 | 180 | 183 | 184 | 185 | 186 | ) 187 | } 188 | 189 | type DocumentProps = { 190 | document: Document 191 | } 192 | 193 | const DocumentComponent: FunctionComponent = ({ document }) => { 194 | const { workspace } = useContext(WorkspaceContext) 195 | const menuButton = useRef(null); 196 | const classes = useStyles({ hasWorkspace: !!workspace }); 197 | const [sharingModalOpen, setSharingModalOpen] = useState(false); 198 | const [backupsDialogOpen, setBackupsDialogOpen] = useState(false); 199 | const [menuOpen, setMenuOpen] = useState(false) 200 | const { aclUri, allowed } = useAccessInfo(document.uri) 201 | const readOnly = !(allowed && allowed.user.has("write")) 202 | const { devMode, setDevMode } = usePreferences() 203 | return (document === undefined) ? () : ( 204 | 205 | 206 | 207 | {isPage(document) ? ( 208 | 209 | ) : ( 210 | 211 | )} 212 |
213 |
214 | { 215 | allowed && allowed.user.has("control") && ( 216 | <> 217 | setDevMode(!devMode)}> 219 | 220 | 221 | setSharingModalOpen(!sharingModalOpen)}> 223 | 224 | 225 | setBackupsDialogOpen(!backupsDialogOpen)}> 227 | 228 | 229 | setMenuOpen(!menuOpen)} > 231 | 232 | 233 | 234 | ) 235 | } 236 |
237 | 238 | 239 | setMenuOpen(false)} 242 | keepMounted 243 | anchorOrigin={{ 244 | vertical: 'top', 245 | horizontal: 'right', 246 | }} 247 | transformOrigin={{ 248 | vertical: 'top', 249 | horizontal: 'right', 250 | }} /> 251 | {workspace && aclUri && ( 252 | 253 | {document && ( setSharingModalOpen(false)} />)} 254 | 255 | )} 256 | {backupsDialogOpen && setBackupsDialogOpen(false)} />} 257 | {allowed && allowed.user.has("read") && ( 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | {isConcept(document) && ( 267 | 268 | 269 | 270 | 271 | 272 | )} 273 | 274 | )} 275 | 276 | ) 277 | } 278 | 279 | export default DocumentComponent; 280 | -------------------------------------------------------------------------------- /src/components/DocumentTextEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useRef, useCallback, useEffect } from 'react' 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Paper from '@material-ui/core/Paper'; 5 | import Box from '@material-ui/core/Box'; 6 | import SaveIcon from '@material-ui/icons/Save' 7 | import { useDebounce } from 'use-debounce'; 8 | 9 | import { Slate, useSlate } from 'slate-react'; 10 | import { Node } from 'slate'; 11 | 12 | import { HoveringToolbar } from "./EditorToolbar"; 13 | import Editable, { useNewEditor } from "./Editable"; 14 | import { Document } from "../utils/model" 15 | import { getConceptNodes } from "../utils/slate" 16 | import WorkspaceContext from "../context/workspace"; 17 | import { useBackups } from '../hooks/backup'; 18 | import { usePreferences } from '../context/preferences' 19 | 20 | const useStyles = makeStyles(theme => ({ 21 | saving: { 22 | position: "fixed", 23 | right: theme.spacing(0), 24 | top: "78px", 25 | zIndex: 1000, 26 | color: theme.palette.primary.light 27 | }, 28 | editor: { 29 | height: "100%", 30 | overflow: "scroll" 31 | }, 32 | editable: { 33 | marginTop: theme.spacing(6), 34 | textAlign: "left", 35 | padding: theme.spacing(1), 36 | paddingLeft: theme.spacing(8), 37 | paddingRight: theme.spacing(8), 38 | paddingTop: 0, 39 | background: "white", 40 | height: "100%", 41 | flexGrow: 2 42 | }, 43 | devConsole: { 44 | flexGrow: 1, 45 | fontSize: "0.66em", 46 | textAlign: "left" 47 | } 48 | })); 49 | 50 | interface DocumentTextEditorProps { 51 | document: Document, 52 | readOnly: boolean 53 | } 54 | 55 | export default function DocumentTextEditor({ document, readOnly }: DocumentTextEditorProps) { 56 | const documentUri = document.uri 57 | const { updateText } = useContext(WorkspaceContext); 58 | const classes = useStyles(); 59 | const [saving, setSaving] = useState(false); 60 | const documentText = document.text; 61 | const [editorValue, setEditorValue] = useState(undefined); 62 | const [saveNeeded, setSaveNeeded] = useState(false); 63 | const [debouncedValue] = useDebounce(editorValue, 1500); 64 | const savedVersionsRef = useRef>([]) 65 | const setSavedVersions = useCallback<(mutate: (current: Array) => Array) => void>( 66 | (mutate) => { 67 | savedVersionsRef.current = mutate(savedVersionsRef.current) 68 | }, 69 | [savedVersionsRef] 70 | ) 71 | const editor = useNewEditor() 72 | 73 | useEffect(() => { 74 | // set editor text to null when the document changes so we won't save document text from another document to the current document 75 | editor.children = undefined 76 | setEditorValue(undefined); 77 | savedVersionsRef.current = [] 78 | }, [editor, documentUri]) 79 | 80 | useBackups(document, editorValue) 81 | 82 | const previouslySaved = useCallback( 83 | (text) => savedVersionsRef.current.some(previousVersion => previousVersion === text), 84 | [savedVersionsRef] 85 | ) 86 | 87 | useEffect(() => { 88 | // once documentText loads, set editorValue 89 | if ((documentText !== undefined) && (documentText !== null)) { 90 | setEditorValue(currentValue => { 91 | if ((JSON.stringify(currentValue) === documentText) || 92 | previouslySaved(documentText)) { 93 | return currentValue 94 | } else { 95 | return JSON.parse(documentText) 96 | } 97 | }) 98 | } 99 | // include documentUri here so that we run this after setting editorValue to undefined above 100 | }, [documentUri, documentText, previouslySaved, savedVersionsRef]); 101 | 102 | useEffect(() => { 103 | const maybeSave = async () => { 104 | const saveableText = JSON.stringify(debouncedValue); 105 | if (saveableText !== documentText) { 106 | setSaving(true); 107 | if (updateText) { 108 | const conceptUris = getConceptNodes(editor).map(([concept]) => concept.uri) 109 | await updateText(document, saveableText, conceptUris); 110 | } 111 | setSavedVersions(currentSavedVersions => [saveableText, ...currentSavedVersions].slice(0, 100)) 112 | setSaving(false); 113 | } 114 | } 115 | if (saveNeeded) { 116 | setSaveNeeded(false); 117 | maybeSave(); 118 | } 119 | }, [saveNeeded, document, documentText, debouncedValue, updateText, setSavedVersions, editor]) 120 | 121 | useEffect(() => { 122 | if (debouncedValue !== undefined) { 123 | setSaveNeeded(true); 124 | } 125 | }, [debouncedValue]) 126 | const { devMode } = usePreferences() 127 | return ( 128 | 129 | {saving && } 130 | {editorValue === undefined ? ( 131 |
Loading...
132 | ) : ( 133 | setEditorValue(newValue)}> 136 | {!readOnly && ( 137 | <> 138 | 139 | 140 | )} 141 | 142 | 144 | {devMode && ()} 145 | 146 | 147 | )} 148 |
149 | ); 150 | } 151 | 152 | const DevConsole = () => { 153 | const editor = useSlate() 154 | const classes = useStyles() 155 | return ( 156 | 157 | 158 |
159 |           {JSON.stringify(editor.children, null, 2)}
160 |         
161 |
162 |           {JSON.stringify(editor.selection, null, 2)}
163 |         
164 |
165 |
166 | ) 167 | } 168 | -------------------------------------------------------------------------------- /src/components/Editable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, ReactNode, FunctionComponent } from 'react'; 2 | import { createEditor, Text, Editor } from 'slate'; 3 | import { Editable as SlateEditable, withReact } from 'slate-react'; 4 | import isHotkey from 'is-hotkey'; 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | import { withHistory } from 'slate-history'; 9 | 10 | import { 11 | withImages, withLinks, withChecklists, withLists, toggleMark, 12 | withTables, withEmbeds, withConcepts 13 | } from '../utils/editor'; 14 | 15 | import ChecklistItemElement from './ChecklistItemElement' 16 | import EmbedElement from './EmbedElement' 17 | import LinkElement from './edit/LinkElement' 18 | import ConceptElement from './edit/ConceptElement' 19 | import ImageElement from './edit/ImageElement' 20 | import Block from './edit/Block' 21 | import Table from "./edit/Table" 22 | import { ElementProps } from "./edit/" 23 | 24 | const useStyles = makeStyles(theme => ({ 25 | blockquote: { 26 | backgroundColor: theme.palette.grey[100], 27 | marginLeft: theme.spacing(1), 28 | marginTop: 0, 29 | marginRight: theme.spacing(1), 30 | marginBottom: 0, 31 | paddingLeft: theme.spacing(1), 32 | paddingTop: theme.spacing(1), 33 | paddingRight: theme.spacing(1), 34 | paddingBottom: theme.spacing(1) 35 | }, 36 | orderedList: { 37 | paddingLeft: theme.spacing(3) 38 | }, 39 | unorderedList: { 40 | paddingLeft: theme.spacing(3) 41 | }, 42 | paragraph: { 43 | marginTop: theme.spacing(1), 44 | marginBottom: theme.spacing(1), 45 | whiteSpace: "pre-wrap", 46 | wordBreak: "break-word" 47 | }, 48 | table: { 49 | border: "1px solid black", 50 | borderCollapse: "collapse" 51 | }, 52 | tr: { 53 | }, 54 | td: { 55 | border: `2px solid ${theme.palette.grey[300]}`, 56 | padding: theme.spacing(1) 57 | }, 58 | columnButtons: { 59 | display: "flex", 60 | flexDirection: "column", 61 | verticalAlign: "top" 62 | }, 63 | rowButtons: { 64 | display: "flex" 65 | }, 66 | })) 67 | 68 | const HOTKEYS: { [key: string]: string } = { 69 | 'mod+b': 'bold', 70 | 'mod+i': 'italic', 71 | 'mod+u': 'underline', 72 | 'mod+`': 'code', 73 | } 74 | 75 | export type LeafProps = { 76 | attributes: { [key: string]: any }, 77 | children: ReactNode, 78 | leaf: Text 79 | } 80 | 81 | const Leaf: FunctionComponent = ({ attributes, children, leaf }) => { 82 | if (leaf.bold) { 83 | children = {children} 84 | } 85 | 86 | if (leaf.code) { 87 | children = {children} 88 | } 89 | 90 | if (leaf.italic) { 91 | children = {children} 92 | } 93 | 94 | if (leaf.underline) { 95 | children = {children} 96 | } 97 | 98 | return {children} 99 | } 100 | 101 | 102 | 103 | const Element: FunctionComponent = (props) => { 104 | const { attributes, children, element } = props; 105 | const classes = useStyles() 106 | switch (element.type) { 107 | case 'embed': 108 | return 109 | case 'block-quote': 110 | return
{children}
111 | case 'heading-one': 112 | return

{children}

113 | case 'heading-two': 114 | return

{children}

115 | case 'heading-three': 116 | return

{children}

117 | case 'bulleted-list': 118 | return
    {children}
119 | case 'numbered-list': 120 | return
    {children}
121 | case 'list-item': 122 | return
  • {children}
  • 123 | case 'image': 124 | return 125 | case 'link': 126 | return 127 | case 'concept': 128 | return 129 | case 'check-list-item': 130 | return 131 | case 'table': 132 | return ( 133 | 134 | 135 | 136 | ) 137 | case 'table-row': 138 | return {children} 139 | case 'table-cell': 140 | return 141 | default: 142 | return

    {children}

    143 | } 144 | } 145 | 146 | export const useNewEditor = () => useMemo(() => withConcepts(withEmbeds(withTables(withLists(withChecklists(withLinks(withImages(withReact(withHistory(createEditor()))))))))), []) 147 | 148 | type EditableProps = { 149 | editor: Editor, 150 | readOnly?: boolean 151 | } & React.TextareaHTMLAttributes 152 | 153 | const Editable: FunctionComponent = ({ editor, ...props }) => { 154 | const renderLeaf = useCallback(props => , []) 155 | const renderElement = useCallback(props => , []) 156 | const { t } = useTranslation() 157 | return { 163 | for (const hotkey in HOTKEYS) { 164 | if (isHotkey(hotkey, event.nativeEvent)) { 165 | event.preventDefault() 166 | const mark = HOTKEYS[hotkey] 167 | toggleMark(editor, mark) 168 | } 169 | } 170 | }} 171 | {...props} /> 172 | } 173 | 174 | export default Editable 175 | -------------------------------------------------------------------------------- /src/components/EditorToolbar.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, useContext } from 'react'; 2 | import { ReactEditor, useSlate, useEditor } from 'slate-react'; 3 | import { Editor, Range, Transforms } from 'slate' 4 | 5 | import { makeStyles, useTheme } from '@material-ui/core/styles'; 6 | 7 | import Toolbar from '@material-ui/core/Toolbar'; 8 | import TextField from '@material-ui/core/TextField'; 9 | import Popover from '@material-ui/core/Popover'; 10 | import Button from '@material-ui/core/Button'; 11 | import FormatBold from '@material-ui/icons/FormatBold'; 12 | import FormatItalic from '@material-ui/icons/FormatItalic'; 13 | import FormatUnderlined from '@material-ui/icons/FormatUnderlined'; 14 | import Code from '@material-ui/icons/Code'; 15 | import FormatQuote from '@material-ui/icons/FormatQuote'; 16 | import FormatListBulleted from '@material-ui/icons/FormatListBulleted'; 17 | import FormatListNumbered from '@material-ui/icons/FormatListNumbered'; 18 | import ImageIcon from '@material-ui/icons/ImageOutlined'; 19 | import LinkIcon from '@material-ui/icons/Link'; 20 | import CheckBox from '@material-ui/icons/CheckBoxOutlined'; 21 | import ConceptIcon from '@material-ui/icons/Toll'; 22 | 23 | import IconButton from './IconButton'; 24 | 25 | import { 26 | isMarkActive, toggleMark, isBlockActive, toggleBlock, insertImage, 27 | isLinkActive, insertLink, isConceptActive, insertConcept 28 | } from '../utils/editor' 29 | import { conceptUri } from '../utils/urls' 30 | import WorkspaceContext from '../context/workspace' 31 | 32 | const useStyles = makeStyles(theme => ({ 33 | toolbarRoot: { 34 | pointerEvents: "none" 35 | }, 36 | toolbar: { 37 | pointerEvents: "auto" 38 | }, 39 | insertLinkMenuRoot: { 40 | pointerEvents: "none" 41 | }, 42 | insertLinkMenu: { 43 | pointerEvents: "auto" 44 | } 45 | })) 46 | 47 | const InsertImageButton = () => { 48 | const editor = useEditor() 49 | return ( 50 | { 54 | event.preventDefault() 55 | const url = window.prompt('Enter the URL of the image:') 56 | if (!url) return 57 | insertImage(editor, url) 58 | }} 59 | > 60 | 61 | 62 | ) 63 | } 64 | 65 | const LinkButton = ({onOpen, onClose}) => { 66 | const editor = useSlate() 67 | const [linkButtonOpen, setLinkButtonOpen] = useState(false) 68 | const [url, setUrl] = useState(null) 69 | const [selection, setSelection] = useState(undefined) 70 | const ref = useRef() 71 | const onClosePopover = () => { 72 | setLinkButtonOpen(false) 73 | onClose() 74 | } 75 | const insertAndClose = () => { 76 | Transforms.select(editor, selection) 77 | insertLink(editor, url) 78 | onClosePopover() 79 | } 80 | return ( 81 | <> 82 | { 88 | onOpen() 89 | setSelection(editor.selection) 90 | setLinkButtonOpen(!linkButtonOpen) 91 | }} 92 | > 93 | 94 | 95 | 108 | { 112 | if (e.keyCode === 13) { 113 | e.preventDefault() 114 | insertAndClose() 115 | } 116 | }} 117 | onChange={e => { 118 | setUrl(e.target.value) 119 | }}/> 120 | 125 | 126 | 127 | ) 128 | } 129 | 130 | const MarkButton = ({ format, icon, ...props }) => { 131 | const editor = useSlate() 132 | return ( 133 | { 137 | event.preventDefault() 138 | toggleMark(editor, format) 139 | }} 140 | {...props} 141 | > 142 | {icon} 143 | 144 | ) 145 | } 146 | 147 | const selectedText = (editor) => { 148 | if (editor && editor.selection) { 149 | if (Range.isCollapsed(editor.selection)){ 150 | return "" 151 | } else { 152 | return Editor.string(editor, editor.selection) 153 | } 154 | } else { 155 | return null 156 | } 157 | } 158 | 159 | const ConceptButton = ({onOpen, onClose}) => { 160 | const {workspace} = useContext(WorkspaceContext) 161 | const editor = useSlate() 162 | const [conceptButtonOpen, setConceptButtonOpen] = useState(false) 163 | const [name, setName] = useState(null) 164 | const [selection, setSelection] = useState(undefined) 165 | const ref = useRef() 166 | const onClosePopover = () => { 167 | setConceptButtonOpen(false) 168 | onClose() 169 | } 170 | const insertAndClose = () => { 171 | Transforms.select(editor, selection) 172 | insertConcept(editor, name, conceptUri(workspace.conceptContainerUri, name)) 173 | onClosePopover() 174 | } 175 | return ( 176 | <> 177 | { 183 | onOpen() 184 | setSelection(editor.selection) 185 | setName(selectedText(editor)) 186 | setConceptButtonOpen(!conceptButtonOpen) 187 | }} 188 | > 189 | 190 | 191 | 204 | { 208 | if (e.keyCode === 13) { 209 | e.preventDefault() 210 | insertAndClose() 211 | } 212 | }} 213 | onChange={e => setName(e.target.value)}/> 214 | 219 | 220 | 221 | ) 222 | } 223 | 224 | const BlockButton = ({ format, icon, ...props }) => { 225 | const editor = useSlate() 226 | return ( 227 | { 231 | event.preventDefault() 232 | toggleBlock(editor, format) 233 | }} 234 | {...props} 235 | > 236 | {icon} 237 | 238 | ) 239 | } 240 | 241 | export default function EditorToolbar(props){ 242 | return ( 243 | 244 | 245 | 246 | } /> 247 | } /> 248 | } /> 249 | 250 | } /> 251 | 252 | ) 253 | } 254 | 255 | export function HoveringToolbar() { 256 | const [submenuOpen, setSubmenuOpen] = useState(false) 257 | const editor = useSlate() 258 | const open = submenuOpen || !!(editor.selection && !Range.isCollapsed(editor.selection)) 259 | const theme = useTheme() 260 | const classes = useStyles() 261 | 262 | const [anchorPosition, setAnchorPosition] = useState({top: 0, left: 0}) 263 | const onOpenSubmenu = () => { 264 | setSubmenuOpen(true) 265 | } 266 | const onCloseSubmenu = () => { 267 | setSubmenuOpen(false) 268 | setTimeout(() => { 269 | ReactEditor.focus(editor) 270 | const selection = editor.selection 271 | // manual focus seems to trigger a process that resets the selection, so reset it 272 | setTimeout(() => { 273 | Transforms.select(editor, selection) 274 | // this 200 second timeout here is wonky but seems to work 275 | // TODO: test on other platforms, figure out a better way to do this 276 | // this will probably be fixed by https://github.com/ianstormtaylor/slate/issues/3412 277 | }, 150) 278 | }, 0) 279 | } 280 | useEffect(() => { 281 | if (editor.selection){ 282 | const domSelection = window.getSelection() 283 | const domRange = domSelection.getRangeAt(0) 284 | const rect = domRange.getBoundingClientRect() 285 | setAnchorPosition({top: rect.top - theme.spacing(1), left: rect.left}) 286 | } 287 | }, [editor.selection, theme]) 288 | return ( 289 | 307 | } /> 308 | } /> 309 | } /> 310 | } /> 311 | 312 | 313 | 314 | ) 315 | } 316 | -------------------------------------------------------------------------------- /src/components/EmbedElement.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | 3 | import auth from 'solid-auth-client' 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | 7 | import {BlockLoader} from './Loader'; 8 | 9 | const useStyles = makeStyles(theme => ({ 10 | table: { 11 | border: "1px solid black", 12 | borderCollapse: "collapse" 13 | }, 14 | tr: { 15 | }, 16 | td: { 17 | border: `2px solid ${theme.palette.grey[300]}`, 18 | padding: theme.spacing(1) 19 | } 20 | })) 21 | 22 | const CSVEmbed = ({url}) => { 23 | const classes = useStyles() 24 | const [data, setData] = useState() 25 | const headerData = data && (data.length > 0) && data[0] 26 | const bodyData = data && (data.length > 0) && data.slice(1) 27 | useEffect(() => { 28 | async function loadData(){ 29 | const response = await auth.fetch(url) 30 | if (response.ok){ 31 | const text = await response.text() 32 | const rows = text.split("\n") 33 | setData(rows.map(row => row.split(","))) 34 | } else { 35 | console.log("data failed to load: ", response) 36 | } 37 | } 38 | loadData() 39 | }, [url]) 40 | return data ? ( 41 |
    {children}
    42 | 43 | 44 | {headerData.map((cell, i) => ( 45 | 46 | ))} 47 | 48 | 49 | 50 | {bodyData.map((row, i) => ( 51 | 52 | {row.map((cell, i) => ( 53 | 56 | ))} 57 | 58 | ))} 59 | 60 |
    {cell}
    54 | {cell} 55 |
    61 | ) : ( 62 | 63 | ) 64 | } 65 | 66 | const Embed = ({type, url}) => { 67 | if (type === "text/csv") { 68 | return 69 | } else { 70 | return

    Don't know how to display {url}

    71 | } 72 | } 73 | 74 | const EmbedElement = ({element, attributes, children, ...props}) => { 75 | return ( 76 |
    77 |
    78 | 79 |
    80 | {children} 81 |
    82 | ) 83 | } 84 | 85 | export default EmbedElement 86 | -------------------------------------------------------------------------------- /src/components/EmbedPicker.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | 3 | import auth from 'solid-auth-client' 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Button from '@material-ui/core/Button'; 7 | import Dialog from '@material-ui/core/Dialog'; 8 | import DialogContent from '@material-ui/core/DialogContent'; 9 | import DialogContentText from '@material-ui/core/DialogContentText'; 10 | import DialogActions from '@material-ui/core/DialogActions'; 11 | import TextField from '@material-ui/core/TextField'; 12 | 13 | import Loader from './Loader'; 14 | 15 | const useStyles = makeStyles(theme => ({ 16 | urlTextField: { 17 | width: "100%", 18 | marginTop: theme.spacing(2), 19 | } 20 | })) 21 | 22 | export default function EmbedPicker({onSave, onClose, ...props}){ 23 | const classes = useStyles() 24 | const [saving, setSaving] = useState() 25 | const [error, setError] = useState() 26 | const [url, setUrl] = useState() 27 | const save = async () => { 28 | setError(null) 29 | setSaving(true) 30 | try { 31 | const response = await auth.fetch(url, {method: 'HEAD'}) 32 | if (response.ok){ 33 | const type = response.headers.get("content-type").split(";")[0] 34 | await onSave(url, type) 35 | } else { 36 | setError(`Error loading ${url}`) 37 | } 38 | } catch (e){ 39 | if (e.name === "TypeError"){ 40 | setError(`Could not load ${url}: the resource probably doesn't exist or does not have the correct CORS configuration.`) 41 | } else { 42 | setError(`Error loading ${url}: ${e.message}`) 43 | } 44 | } 45 | setSaving(false) 46 | } 47 | return ( 48 | e.stopPropagation()}> 49 | 50 | {error && ( 51 | 52 | {error} 53 | 54 | )} 55 | setUrl(e.target.value)}/> 59 | 60 | 61 | {saving ? ( 62 | 63 | ) : ( 64 | <> 65 | 68 | 71 | 72 | )} 73 | 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /src/components/FAQ.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import Grid from '@material-ui/core/Grid'; 6 | import Link from '@material-ui/core/Link'; 7 | 8 | import { titleFont } from '../utils/fonts' 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | question: { 12 | fontFamily: titleFont, 13 | marginTop: theme.spacing(3), 14 | marginBottom: theme.spacing(2) 15 | }, 16 | answer: { 17 | ...theme.typography.body2, 18 | "& p": { 19 | marginBottom: theme.spacing(2) 20 | } 21 | } 22 | })) 23 | 24 | export const Question = ({children}) => { 25 | const classes = useStyles() 26 | return ( 27 | 28 | 29 | 30 | {children} 31 | 32 | 33 | 34 | ) 35 | } 36 | 37 | export const Answer = ({children}) => { 38 | const classes = useStyles() 39 | return ( 40 | 41 | 42 | 43 | 44 | {children} 45 | 46 | 47 | 48 | 49 | ) 50 | } 51 | 52 | export const A = (props) => 53 | -------------------------------------------------------------------------------- /src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default () =>

    Welcome to Concept! Add or select a page on the left to get started.

    4 | -------------------------------------------------------------------------------- /src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref, PropsWithChildren } from 'react'; 2 | import IconButton, { IconButtonProps } from '@material-ui/core/IconButton'; 3 | 4 | import Tooltip from '@material-ui/core/Tooltip'; 5 | 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | 8 | const useStyles = makeStyles(theme => ({ 9 | active: { 10 | background: theme.palette.grey[300] 11 | }, 12 | inactive: { 13 | 14 | } 15 | })) 16 | 17 | interface Props extends IconButtonProps { 18 | active?: boolean, 19 | title?: string, 20 | ariaLabel?: string 21 | } 22 | 23 | const MyIconButton = forwardRef(({ active, title, ariaLabel, ...props }: PropsWithChildren, ref: Ref) => { 24 | const classes = useStyles(); 25 | const button = ( 26 | 30 | ) 31 | return title ? ( 32 | 33 | {button} 34 | 35 | ) : button 36 | }) 37 | 38 | export default MyIconButton 39 | -------------------------------------------------------------------------------- /src/components/ImageUploader.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | 3 | import { Transforms } from 'slate'; 4 | import { useEditor } from 'slate-react'; 5 | import Cropper from 'react-cropper'; 6 | import 'cropperjs/dist/cropper.css'; 7 | import uuid from 'uuid/v1'; 8 | import auth from 'solid-auth-client'; 9 | 10 | import { makeStyles } from '@material-ui/core/styles'; 11 | import Button from '@material-ui/core/Button'; 12 | import Dialog from '@material-ui/core/Dialog'; 13 | import DialogContent from '@material-ui/core/DialogContent'; 14 | import DialogActions from '@material-ui/core/DialogActions'; 15 | import TextField from '@material-ui/core/TextField'; 16 | 17 | 18 | import { insertionPoint, insertImage } from '../utils/editor'; 19 | import Loader from './Loader'; 20 | 21 | const useStyles = makeStyles(theme => ({ 22 | uploader: { 23 | }, 24 | cropper: { 25 | maxHeight: theme.spacing(50) 26 | }, 27 | previewImage: { 28 | display: "block", 29 | height: theme.spacing(30), 30 | width: "auto", 31 | marginLeft: "auto", 32 | marginRight: "auto", 33 | }, 34 | altTextField: { 35 | width: "100%", 36 | marginTop: theme.spacing(2), 37 | } 38 | })) 39 | 40 | const ImageEditingModal = ({src, onSave, onClose, ...props}) => { 41 | const [saving, setSaving] = useState() 42 | const classes = useStyles() 43 | const cropper = useRef() 44 | const save = async () => { 45 | setSaving(true) 46 | await onSave(cropper.current.getCroppedCanvas()) 47 | setSaving(false) 48 | } 49 | return ( 50 | 51 | 52 | 58 | 63 | 64 | 65 | {saving ? ( 66 | 67 | ) : ( 68 | <> 69 | 72 | 75 | 76 | )} 77 | 78 | 79 | ) 80 | } 81 | 82 | const typesToExts = { 83 | "image/gif": "gif", 84 | "image/jpeg": "jpg", 85 | "image/png": "png", 86 | "image/svg+xml": "svg", 87 | "image/webp": "webp" 88 | } 89 | 90 | const extForFile = file => { 91 | const extFromType = typesToExts[file.type] 92 | if (extFromType) { 93 | return extFromType 94 | } else { 95 | return file.name.split(".").slice(-1)[0] 96 | } 97 | } 98 | 99 | const nameForFile = file => `${uuid()}.${extForFile(file)}` 100 | 101 | const uploadFromCanvas = (canvas, uri, type) => new Promise((resolve, reject) => { 102 | canvas.toBlob(async (blob) => { 103 | const response = await auth.fetch(uri, { 104 | method: 'PUT', 105 | force: true, 106 | headers: { 107 | 'content-type': type, 108 | credentials: 'include' 109 | }, 110 | body: blob 111 | }); 112 | if (response.ok){ 113 | resolve(response) 114 | } else { 115 | reject(response) 116 | console.log("image upload failed: ", response) 117 | } 118 | }, type, 1) 119 | 120 | }) 121 | 122 | const uploadFromFile = (file, uri) => new Promise((resolve, reject) => { 123 | const reader = new FileReader() 124 | reader.onload = async f => { 125 | const response = await auth.fetch(uri, { 126 | method: 'PUT', 127 | force: true, 128 | headers: { 129 | 'content-type': file.type, 130 | credentials: 'include' 131 | }, 132 | body: f.target.result 133 | }); 134 | if (response.ok){ 135 | resolve(response) 136 | } else { 137 | reject(response) 138 | } 139 | } 140 | reader.readAsArrayBuffer(file); 141 | }) 142 | 143 | const uriForOriginal = (editedUri) => { 144 | const parts = editedUri.split(".") 145 | return [...parts.slice(0, -1), "original", ...parts.slice(-1)].join(".") 146 | } 147 | 148 | export function ImageEditor({element, onClose, onSave, ...props}) { 149 | 150 | const {url, originalUrl, mime} = element 151 | return ( 152 | { 155 | await uploadFromCanvas(canvas, url, mime) 156 | onSave(url) 157 | }} {...props}/> 158 | ) 159 | } 160 | 161 | export default ({element, onClose, uploadDirectory, ...props}) => { 162 | const classes = useStyles() 163 | const inputRef = useRef() 164 | const editor = useEditor() 165 | const [file, setFile] = useState() 166 | const [originalSrc, setOriginalSrc] = useState() 167 | const [previewSrc, setPreviewSrc] = useState() 168 | const [altText, setAltText] = useState("") 169 | const [croppedCanvas, setCroppedCanvas] = useState() 170 | const [editing, setEditing] = useState(false) 171 | 172 | const insert = async () => { 173 | const editedUri = `${uploadDirectory}${nameForFile(file)}` 174 | const originalUri = uriForOriginal(editedUri) 175 | uploadFromFile(file, originalUri) 176 | await uploadFromCanvas(croppedCanvas, editedUri, file.type) 177 | const insertAt = insertionPoint(editor, element) 178 | insertImage(editor, {url: editedUri, originalUrl: originalUri, alt: altText, mime: file.type}, insertAt); 179 | Transforms.select(editor, insertAt) 180 | onClose() 181 | } 182 | 183 | useEffect(() => { 184 | let newSrc; 185 | if (file){ 186 | newSrc = URL.createObjectURL(file) 187 | setOriginalSrc(newSrc) 188 | setPreviewSrc(newSrc) 189 | setEditing(true) 190 | } 191 | return () => { 192 | if (newSrc){ 193 | URL.revokeObjectURL(newSrc) 194 | } 195 | } 196 | }, [file]) 197 | 198 | const onFileChanged = event => { 199 | if (event.target.files) { 200 | const file = event.target.files[0] 201 | setFile(file) 202 | } 203 | } 204 | 205 | return ( 206 | 207 | 208 | {previewSrc && ( 209 | <> 210 | {altText}/ 211 | setAltText(e.target.value)}/> 214 | 215 | )} 216 | 217 | 218 | 221 | {croppedCanvas && 222 | <> 223 | 226 | 229 | 230 | } 231 | 234 | 235 | 242 | { 245 | setPreviewSrc(canvas.toDataURL(file.type)) 246 | setCroppedCanvas(canvas) 247 | setEditing(false) 248 | }}/> 249 | 250 | ) 251 | } 252 | -------------------------------------------------------------------------------- /src/components/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Link } from 'react-router-dom'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Grid from '@material-ui/core/Grid'; 7 | import Button from '@material-ui/core/Button'; 8 | 9 | import { LogInButton } from './LogInLogOutButton' 10 | import { AppTitleGridRow } from './AppTitle' 11 | import SocialIcons from './SocialIcons' 12 | import logo from '../logo.svg' 13 | 14 | 15 | const useStyles = makeStyles(theme => ({ 16 | name: { 17 | marginTop: theme.spacing(3), 18 | fontFamily: "'Special Elite', cursive" 19 | }, 20 | logo: { 21 | width: theme.spacing(18) 22 | }, 23 | actionButton: { 24 | fontSize: "1.6rem" 25 | }, 26 | social: { 27 | marginTop: theme.spacing(6) 28 | }, 29 | socialIcon: { 30 | color: "inherit" 31 | } 32 | })) 33 | 34 | function LandingPage() { 35 | const classes = useStyles({}) 36 | return ( 37 | <> 38 | 39 | 40 | 41 | Concept Logo 42 | 43 | 44 | 45 | 46 | 47 | 48 |

    Hello Friend!

    49 |

    50 | I'm sorry to let you know that Concept is no longer supported or 51 | actively developed. 52 |

    53 |

    54 | But good news! 55 |

    56 |

    57 | This code lives on (in some 58 | cases literally) in itme.online, 59 | a Grant for the Web supported project from itme. 60 |

    61 |

    62 | If you're interested in following along, please subscribe to  63 | our blog or newsletter for 64 | the latest updates. 65 |

    66 |
    67 |
    68 | 69 | 70 | 71 | 72 | 76 | 77 | 78 | log in 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | ) 91 | } 92 | 93 | export default LandingPage 94 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, forwardRef, Ref } from 'react' 2 | import { Link as RouterLink, LinkProps as RouterLinkProps } from "react-router-dom"; 3 | import MuiLink, { LinkProps as MuiLinkProps } from '@material-ui/core/Link'; 4 | 5 | type Partial = { 6 | [P in keyof T]?: T[P]; 7 | } 8 | 9 | export type LinkProps = MuiLinkProps & Partial 10 | 11 | const Link: FunctionComponent = forwardRef((props, ref: Ref) => { 12 | if (props.href) { 13 | return 14 | } else { 15 | return 16 | } 17 | }) 18 | 19 | export default Link 20 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | 3 | import "react-loader-spinner/dist/loader/css/react-spinner-loader.css" 4 | import Loader from 'react-loader-spinner' 5 | import { useTheme } from '@material-ui/core/styles'; 6 | 7 | type Types = 'Triangle' | 'ThreeDots' 8 | 9 | type Props = { height?: number, width?: number, type?: Types, className?: string } 10 | 11 | const ConceptLoader = ({ height, width, type = "Triangle", ...props }: PropsWithChildren) => { 12 | const theme = useTheme(); 13 | return ( 14 | 19 | ) 20 | } 21 | 22 | export const ButtonLoader = () => 23 | export const BlockLoader = () => 24 | 25 | export default ConceptLoader; 26 | -------------------------------------------------------------------------------- /src/components/LogInLogOutButton.js: -------------------------------------------------------------------------------- 1 | import React, {useContext} from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | 4 | import AuthContext from "../context/auth" 5 | import {useLoggedIn} from '@solid/react'; 6 | 7 | export function LogInButton(props) { 8 | const {logIn} = useContext(AuthContext) 9 | return ( 10 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ProfileLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { foaf } from 'rdf-namespaces'; 3 | import { useValueQuery } from '../hooks/data'; 4 | import { handleHausUriForWebId, handleProfilePath, webIdProfilePath } from '../utils/urls' 5 | import Link, { LinkProps } from './Link' 6 | 7 | 8 | type ProfileLinkProps = { webId: string | undefined } & LinkProps 9 | 10 | const ProfileLink: FunctionComponent = ({ webId, ...props }) => { 11 | const [handle] = useValueQuery(webId && handleHausUriForWebId(webId), foaf.nick) 12 | const profilePath = handle ? handleProfilePath(handle) : webId ? webIdProfilePath(webId) : "" 13 | return ( 14 | 15 | ) 16 | } 17 | 18 | export default ProfileLink 19 | -------------------------------------------------------------------------------- /src/components/PublicProfile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { space, schema, vcard, foaf } from 'rdf-namespaces'; 4 | import { useParams } from "react-router-dom"; 5 | import { useWebId, LiveUpdate } from "@solid/react"; 6 | 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import List from '@material-ui/core/List'; 9 | import ListItem from '@material-ui/core/ListItem'; 10 | import Grid from '@material-ui/core/Grid'; 11 | import Paper from '@material-ui/core/Paper'; 12 | import Typography from '@material-ui/core/Typography'; 13 | import Button from '@material-ui/core/Button'; 14 | 15 | import { useListValuesQuery, useListQuery, useValueQuery } from '../hooks/data'; 16 | import { follow, unfollow } from '../utils/data'; 17 | import { conceptContainerUrl, publicPagesUrl } from '../utils/urls'; 18 | import { metaForPageUri } from '../utils/model' 19 | import { pagePath } from '../utils/urls' 20 | 21 | import Loader, { ButtonLoader } from "./Loader"; 22 | import Link from './Link' 23 | import ProfileLink from './ProfileLink' 24 | 25 | const useStyles = makeStyles(theme => ({ 26 | profile: { 27 | padding: theme.spacing(2), 28 | height: "100%" 29 | }, 30 | profileImage: { 31 | width: theme.spacing(10) 32 | }, 33 | name: { 34 | textAlign: "left" 35 | }, 36 | followButton: { 37 | ...theme.typography.button 38 | } 39 | })); 40 | 41 | function PublicPage({ pageUri }: { pageUri: string }) { 42 | const [name] = useValueQuery(pageUri, schema.name, { source: pageUri && metaForPageUri(pageUri) }) 43 | return ( 44 | 45 | 46 | {name || ""} 47 | 48 | 49 | ) 50 | } 51 | 52 | function PublicPages({ url }: { url: string }) { 53 | const [pageUriTerms] = useListQuery(url, schema.itemListElement) 54 | return ( 55 | 56 | 57 | 58 | Public Pages 59 | 60 | 61 | {pageUriTerms && pageUriTerms.map((pageUriTerm: any) => ( 62 | 63 | ))} 64 | 65 | ) 66 | } 67 | 68 | function Friend({ webId }: { webId: string }) { 69 | const [name] = useValueQuery(webId, vcard.fn); 70 | return ( 71 | 72 | 73 | {name || ""} 74 | 75 | 76 | ) 77 | } 78 | 79 | function Friends({ webId }: { webId: string }) { 80 | const [friends] = useListQuery(webId, foaf.knows) 81 | return ( 82 | 83 | 84 | 85 | Friends 86 | 87 | 88 | {friends && friends.map((friend: any) => ( 89 | 90 | ))} 91 | 92 | ) 93 | } 94 | 95 | function FollowButton({ webId }: { webId: string }) { 96 | const currentUserWebId = useWebId(); 97 | const [knowsWebIds, knowsLoading] = useListValuesQuery(currentUserWebId, foaf.knows); 98 | const knows = knowsWebIds && knowsWebIds.includes(webId) 99 | const onFollow = async () => { 100 | await follow(currentUserWebId, webId) 101 | } 102 | const onUnfollow = async () => { 103 | await unfollow(currentUserWebId, webId) 104 | } 105 | return ( 106 | 113 | ) 114 | } 115 | 116 | function PublicInfo({ webId }: { webId: string }) { 117 | const currentUserWebId = useWebId(); 118 | const [name] = useValueQuery(webId, vcard.fn); 119 | const [photo] = useValueQuery(webId, vcard.hasPhoto); 120 | const [storage] = useValueQuery(webId, space.storage); 121 | const conceptContainer = storage && conceptContainerUrl(storage) 122 | const publicPages = conceptContainer && publicPagesUrl(conceptContainer) 123 | const classes = useStyles() 124 | return ( 125 | <> 126 | 127 | 128 | {photo && {`${name}} 129 | 130 | 131 | 132 | {name} 133 | 134 | 135 | 136 | 137 | {webId && currentUserWebId && (webId !== currentUserWebId) && ( 138 | 139 | )} 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | {publicPages && } 149 | 150 | 151 | 152 | ) 153 | } 154 | 155 | export function WebIdPublicProfile({ webId }: { webId: string }) { 156 | const classes = useStyles() 157 | 158 | return ( 159 | 160 | {webId ? ( 161 | 162 | ) : ( 163 | 164 | )} 165 | 166 | ) 167 | } 168 | 169 | export function EncodedWebIdPublicProfile() { 170 | const { encodedWebId } = useParams() 171 | if (encodedWebId) { 172 | return ( 173 | 174 | ) 175 | } else { 176 | 177 | } 178 | } 179 | 180 | 181 | 182 | export default function PublicProfile() { 183 | const { handle } = useParams(); 184 | const [webId] = useValueQuery(handle && `https://handle.haus/handles/${handle}#Person`, "https://handle.haus/ontology#webId") 185 | return ( 186 | 187 | ) 188 | } 189 | -------------------------------------------------------------------------------- /src/components/ReferencedByList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, FunctionComponent } from 'react' 2 | 3 | import { Slate } from 'slate-react'; 4 | 5 | import { Ancestor, Node } from 'slate'; 6 | import { schema } from 'rdf-namespaces'; 7 | 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | import Typography from '@material-ui/core/Typography'; 10 | import Paper from '@material-ui/core/Paper'; 11 | import ExpansionPanel from '@material-ui/core/ExpansionPanel'; 12 | import ExpansionPanelSummary from '@material-ui/core/ExpansionPanelSummary'; 13 | import ExpansionPanelDetails from '@material-ui/core/ExpansionPanelDetails'; 14 | 15 | import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; 16 | 17 | import { useTranslation } from 'react-i18next'; 18 | 19 | import Link from './Link'; 20 | import Editable, { useNewEditor } from "./Editable"; 21 | import { Concept, metaForPageUri } from '../utils/model' 22 | import { useValueQuery, usePage } from '../hooks/data' 23 | import { documentPath } from '../utils/urls' 24 | import { getConceptNodesMatchingName } from '../utils/slate' 25 | 26 | const useStyles = makeStyles(theme => ({ 27 | excerpt: { 28 | padding: theme.spacing(1), 29 | backgroundColor: theme.palette.grey[50], 30 | marginTop: theme.spacing(1), 31 | marginBottom: theme.spacing(1) 32 | } 33 | })); 34 | 35 | type RBE = FunctionComponent<{ 36 | node: Ancestor 37 | }> 38 | 39 | const ReferencedByExcerpt: RBE = ({ node }) => { 40 | const classes = useStyles() 41 | const editor = useNewEditor() 42 | return ( 43 | 44 | { }}> 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | type RBID = FunctionComponent<{ 52 | concept: Concept, 53 | referencedBy: string 54 | }> 55 | 56 | const ReferencedByItemDetails: RBID = ({ concept, referencedBy }) => { 57 | const [page] = usePage(referencedBy) 58 | const pageText = page && page.text 59 | const conceptUri = concept.uri 60 | const [excerptNodes, setExcerptNodes] = useState([]) 61 | useEffect(() => { 62 | if (pageText) { 63 | const rootNode = { children: JSON.parse(pageText) } 64 | const paths = getConceptNodesMatchingName(rootNode, concept.name).map(([, path]) => path) 65 | setExcerptNodes(paths.map(path => Node.parent(rootNode, path))) 66 | } 67 | }, [pageText, conceptUri, concept.name]) 68 | return ( 69 |
    70 | {excerptNodes.map((node, i) => )} 71 |
    72 | ) 73 | } 74 | 75 | type ReferencedByItemProps = { 76 | concept: Concept, 77 | referencedBy: string 78 | } 79 | 80 | const ReferencedByItem: FunctionComponent = ({ referencedBy, concept }) => { 81 | const [name] = useValueQuery(referencedBy, schema.name, { source: metaForPageUri(referencedBy) }) 82 | return ( 83 | 84 | }> 85 | {name && {name}} 86 | 87 | 88 | 89 | 90 | 91 | ) 92 | } 93 | 94 | type ReferencedByListProps = { 95 | concept: Concept 96 | } 97 | 98 | const ReferencedByList: FunctionComponent = ({ concept }) => { 99 | const count = concept.referencedBy.length 100 | const { name } = concept 101 | const { t } = useTranslation() 102 | return ( 103 | <> 104 | 105 | {t('referencedByList.title', { count, name })} 106 | 107 | {concept.referencedBy.map(referencedBy => ( 108 | 109 | ))} 110 | 111 | ) 112 | } 113 | 114 | export default ReferencedByList 115 | -------------------------------------------------------------------------------- /src/components/ReportErrorButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import Button from '@material-ui/core/Button'; 3 | import * as Sentry from '@sentry/browser'; 4 | 5 | type ErrorButtonProps = { 6 | eventId: string | undefined 7 | } 8 | const ReportErrorButton: FunctionComponent = ({ eventId }) => { 9 | return ( 10 | 11 | ) 12 | } 13 | 14 | export default ReportErrorButton 15 | -------------------------------------------------------------------------------- /src/components/SharingModal.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useCallback, useContext} from 'react'; 2 | 3 | import {useWebId} from '@solid/react'; 4 | import data from '@solid/query-ldflex'; 5 | import {namedNode} from '@rdfjs/data-model'; 6 | import copy from 'copy-to-clipboard'; 7 | 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | import Button from '@material-ui/core/Button'; 10 | import Dialog from '@material-ui/core/Dialog'; 11 | import DialogContent from '@material-ui/core/DialogContent'; 12 | import DialogContentText from '@material-ui/core/DialogContentText'; 13 | import DialogTitle from '@material-ui/core/DialogTitle'; 14 | import Box from '@material-ui/core/Box'; 15 | import TextField from '@material-ui/core/TextField'; 16 | import IconButton from '@material-ui/core/IconButton'; 17 | import Typography from '@material-ui/core/Typography'; 18 | import Chip from '@material-ui/core/Chip'; 19 | import Avatar from '@material-ui/core/Avatar'; 20 | import Link from '@material-ui/core/Link'; 21 | import Checkbox from '@material-ui/core/Checkbox'; 22 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 23 | 24 | import CloseIcon from '@material-ui/icons/Close'; 25 | import CopyIcon from '@material-ui/icons/FileCopy'; 26 | 27 | import Autocomplete from '@material-ui/lab/Autocomplete'; 28 | 29 | import Add from '@material-ui/icons/Add'; 30 | import { schema, foaf, acl, vcard } from 'rdf-namespaces'; 31 | 32 | import { useDrag, useDrop } from 'react-dnd' 33 | 34 | import Loader from './Loader'; 35 | import { useAclExists, useParentAcl } from '../hooks/acls'; 36 | import { useValueQuery, useListValuesQuery } from '../hooks/data'; 37 | import { createDefaultAcl } from '../utils/acl'; 38 | import { sharingUrl } from '../utils/urls'; 39 | import { addPublicPage, removePublicPage, addPublicAccess, removePublicAccess } from '../utils/data'; 40 | import WorkspaceContext from '../context/workspace'; 41 | 42 | const useStyles = makeStyles(theme => ({ 43 | closeButton: { 44 | position: 'absolute', 45 | right: theme.spacing(1), 46 | top: theme.spacing(1), 47 | color: theme.palette.grey[500] 48 | }, 49 | permissionsType: { 50 | marginTop: theme.spacing(2), 51 | marginBottom: theme.spacing(2) 52 | }, 53 | publicAccess: { 54 | marginTop: theme.spacing(2), 55 | marginBottom: theme.spacing(2) 56 | }, 57 | publicAccessModes: { 58 | padding: theme.spacing(1), 59 | minHeight: theme.spacing(6), 60 | }, 61 | listPublicly: { 62 | padding: theme.spacing(1), 63 | minHeight: theme.spacing(6), 64 | }, 65 | agent: { 66 | cursor: ({readOnly}) => readOnly ? "default" : "pointer", 67 | }, 68 | agents: ({draggedOver}) => ({ 69 | padding: theme.spacing(1), 70 | minHeight: theme.spacing(6), 71 | backgroundColor: theme.palette.grey[draggedOver ? 200 : 50] 72 | }), 73 | sharingLink: { 74 | overflow: "hidden", 75 | whiteSpace: "nowrap", 76 | textOverflow: "ellipsis" 77 | } 78 | })); 79 | 80 | const Agent = ({agent, deleteAgent, readOnlyAcl, permissionsType}) => { 81 | const [avatarUri] = useValueQuery(agent, vcard.hasPhoto) 82 | const [name] = useValueQuery(agent, vcard.fn) 83 | const webId = useWebId(); 84 | const readOnly = readOnlyAcl || (agent === webId) 85 | const [, chip] = useDrag({ 86 | item: { 87 | type: "agent", 88 | draggedPermissionsType: permissionsType, 89 | draggedAgentWebId: agent, 90 | deleteDraggedAgentFromCurrent: deleteAgent 91 | } 92 | }) 93 | const classes = useStyles({readOnly}) 94 | return ( 95 | } 99 | onDelete={readOnly ? null : deleteAgent }/> 100 | ) 101 | } 102 | 103 | const ModeDescription = ({type}) => { 104 | if (type === "Owners") { 105 | return "Owners" 106 | } else if (type === "Writers") { 107 | return "Writers" 108 | } else if (type === "Readers") { 109 | return "Readers" 110 | } else { 111 | return "Custom Permissions" 112 | } 113 | } 114 | 115 | const PermissionsType = ({ aclUri, type, readOnly }) => { 116 | const uri = `${aclUri}#${type}` 117 | const [adding, setAdding] = useState(false) 118 | const [agents] = useListValuesQuery(uri, acl.agent) 119 | const webId = useWebId(); 120 | const [friends] = useListValuesQuery(webId, foaf.knows) 121 | const addAgent = useCallback(async (agent) => { 122 | await data[uri][acl.agent].add(namedNode(agent)) 123 | }, [uri]) 124 | const deleteAgent = useCallback(async (agent) => { 125 | await data[uri][acl.agent].delete(namedNode(agent)) 126 | }, [uri]) 127 | const [{isOver}, drop] = useDrop({ 128 | accept: "agent", 129 | drop: async ({draggedAgentWebId, draggedPermissionsType, deleteDraggedAgentFromCurrent}) => { 130 | if (type !== draggedPermissionsType) { 131 | await Promise.all([ 132 | deleteDraggedAgentFromCurrent(), 133 | addAgent(draggedAgentWebId) 134 | ]) 135 | } 136 | }, 137 | collect: monitor => ({ 138 | isOver: !!monitor.isOver() 139 | }) 140 | }) 141 | const classes = useStyles({draggedOver: isOver}) 142 | return ( 143 | 144 | 145 | 146 | 147 | 148 | {agents && agents.map(agent => ( 149 | deleteAgent(agent)} 152 | key={agent}/> 153 | ))} 154 | {!readOnly && ( 155 | setAdding(true)} size="small"> 156 | 157 | 158 | )} 159 | {adding && ( 160 | friend} 162 | onChange={(e, friend) => { 163 | addAgent(friend) 164 | setAdding(false) 165 | }} 166 | renderInput={params => } 167 | /> 168 | )} 169 | 170 | 171 | ) 172 | } 173 | 174 | const PageName = ({page}) => { 175 | const [name] = useValueQuery(page, schema.name); 176 | return <>{name || ""} 177 | } 178 | 179 | function NoAclContent({page, aclUri}){ 180 | const webId = useWebId() 181 | const {uri: parentAclUri, loading} = useParentAcl(page) 182 | return ( 183 | <> 184 | {loading ? ( 185 | 186 | ) : ( 187 | 188 | 189 | This page is using the permissions of  190 | {parentAclUri && ( 191 | 192 | )} 193 | 194 | 195 | 196 | 197 | 202 | 203 | )} 204 | 205 | 206 | ) 207 | } 208 | 209 | function PublicAccess({page, aclUri}){ 210 | const {workspace} = useContext(WorkspaceContext) 211 | const publicPages = workspace.publicPages 212 | const [saving, setSaving] = useState(false) 213 | const publicAccessUri = `${aclUri}#Public` 214 | const [publicAccessModes] = useListValuesQuery(publicAccessUri, acl.mode); 215 | const read = publicAccessModes && publicAccessModes.includes(acl.Read) 216 | const write = publicAccessModes && publicAccessModes.includes(acl.Write) 217 | const handleChange = useCallback(async event => { 218 | const checked = event.target.checked 219 | const name = event.target.name 220 | setSaving(true) 221 | if (checked){ 222 | await addPublicAccess(publicAccessUri, name) 223 | } else { 224 | await removePublicAccess(publicAccessUri, name, publicPages, page, write) 225 | } 226 | setSaving(false) 227 | }, [page, publicPages, publicAccessUri, write]) 228 | 229 | const [publicDocs] = useListValuesQuery(publicPages, schema.itemListElement); 230 | const listedPublicly = publicDocs && publicDocs.includes(page) 231 | const handleChangeListPublicly = useCallback(async event => { 232 | const checked = event.target.checked 233 | if (checked){ 234 | await addPublicPage(publicPages, page) 235 | } else { 236 | await removePublicPage(publicPages, page) 237 | } 238 | }, [page, publicPages]) 239 | const classes = useStyles() 240 | return ( 241 | 242 | 243 | Public Access 244 | 245 | 246 | {publicAccessModes && ( 247 | <> 248 | 251 | }/> 252 | {read && ( 253 | 256 | }/> 257 | )} 258 | 259 | )} 260 | {saving && } 261 | 262 | {(publicAccessModes && read) && ( 263 | 264 | 267 | }/> 268 | 269 | )} 270 | 271 | ) 272 | } 273 | 274 | function AclContent({page, aclUri}){ 275 | const [name] = useValueQuery(page, schema.name); 276 | const url = sharingUrl(page) 277 | const classes = useStyles(); 278 | return ( 279 | 280 | 281 | Set sharing for {name && name.toString()} 282 | 283 | 284 | Sharing link: 285 | 286 | 287 | copy(url)} title="copy link to clipboard"> 288 | 289 | 290 | {url} 291 | 292 | 293 | 294 | 295 | 296 | 297 | ) 298 | } 299 | 300 | export default function SharingModal({document, aclUri, open, onClose}) { 301 | const {exists, loading} = useAclExists(aclUri) 302 | const classes = useStyles(); 303 | return ( 304 | 305 | 306 | 307 | Sharing 308 | 309 | 310 | 311 | 312 | 313 | {(!exists && loading) ? ( 314 | 315 | ) : ( 316 | exists ? ( 317 | 318 | ) : ( 319 | 320 | ) 321 | )} 322 | 323 | ) 324 | } 325 | -------------------------------------------------------------------------------- /src/components/SignupPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Typography from '@material-ui/core/Typography'; 4 | 5 | import { LogInButton } from './LogInLogOutButton' 6 | import { AppTitleGridRow, MottoGridRow } from './AppTitle' 7 | import { Question, Answer, A } from './FAQ' 8 | import SocialIcons from './SocialIcons' 9 | 10 | function SignupPage() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | How can I sign up? 17 | 18 | 19 | 20 | Concept doesn't store your data. It's a new kind of app built on an emerging web standard called Solid. 21 | 22 | 23 | When you use a Solid app, you bring your own data, stored in your 24 | personal data Pod. 25 | 26 | 27 | To use Concept, first pick a provider and create your Pod. 28 | 29 | 30 | Once you've created your Pod, log in to Concept to get started organizing your world. 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | export default SignupPage 39 | -------------------------------------------------------------------------------- /src/components/SocialIcons.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Grid from '@material-ui/core/Grid'; 4 | import Link from '@material-ui/core/Link'; 5 | import Twitter from '@material-ui/icons/Twitter'; 6 | import GitHub from '@material-ui/icons/GitHub'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | 9 | const useStyles = makeStyles(theme => ({ 10 | social: { 11 | marginTop: theme.spacing(6) 12 | }, 13 | socialIcon: { 14 | color: "inherit" 15 | } 16 | })) 17 | 18 | export default function SocialIcons(){ 19 | const classes = useStyles() 20 | return ( 21 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/UnrecoverableErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import Box from '@material-ui/core/Box'; 6 | 7 | import ReportErrorButton from './ReportErrorButton' 8 | import SocialIcons from './SocialIcons' 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | page: { 12 | marginTop: theme.spacing(9), 13 | marginLeft: theme.spacing(3), 14 | marginRight: theme.spacing(3) 15 | }, 16 | message: { 17 | fontFamily: "'Special Elite', cursive", 18 | marginBottom: theme.spacing(6), 19 | } 20 | })) 21 | 22 | type UnrecoverableErrorPageProps = { 23 | eventId: string | undefined 24 | } 25 | 26 | const UnrecoverableErrorPage: FunctionComponent = ({ eventId }) => { 27 | const classes = useStyles({}) 28 | return ( 29 | 30 | 31 | 32 | Oh dear. We've encountered a possible infinite loop and couldn't recover. Reloading may solve the problem. 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default UnrecoverableErrorPage 46 | -------------------------------------------------------------------------------- /src/components/WhatPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Typography from '@material-ui/core/Typography'; 4 | 5 | import { LogInButton } from './LogInLogOutButton' 6 | import { AppTitleGridRow, MottoGridRow } from './AppTitle' 7 | import { Question, Answer, A } from './FAQ' 8 | import SocialIcons from './SocialIcons' 9 | 10 | function LandingPage() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | What is this? 17 | 18 | 19 | 20 | Concept is a collaborative workspace for organizing the world. It's 21 | pretty simple for now, but with your help we 22 | can make it the best workspace on the web. 23 | 24 | 25 | If you use Google Docs, Dropbox Paper or Notion but want to use an app that gives 26 | you full control over your data, help us make Concept the best of all 27 | possible collaboration tools. 28 | 29 | 30 | 31 | How can I get started? 32 | 33 | 34 | 35 | Concept is built on Solid, an emerging open web standard that puts you back in control of your data. 36 | 37 | 38 | To start using Concept you'll need to create your Pod and 39 | then log in to Concept using your WebId. 40 | 41 | 42 | 43 | Is my data safe? 44 | 45 | 46 | 47 | You're trusting your Pod provider with your data, so ultimately it's 48 | as safe as you think your Pod provider makes it. 49 | 50 | 51 | Concept is in Alpha at the moment, which means we might make 52 | non-backwards-compatible changes to the data model. We can't promise 53 | to support migration between different data formats for now. We're 54 | hoping to move to Beta soon, at which point we'll provide better 55 | support. 56 | 57 | 58 | Your data will always be accessible via your Pod provider's data 59 | browser, and we create backups of your active documents every 1, 5 and 60 | 10 minutes while you're editing. You definitely won't lose data as 61 | long as as your Pod provider doesn't lose data, it might just require 62 | expert-level skills to get your data back for now. 63 | 64 | 65 | 66 | Something went wrong! How do I get help? 67 | 68 | 69 | 70 | For now, please file bug reports in the issue tracker on GitHub. 71 | 72 | 73 | 74 | 75 | ) 76 | } 77 | 78 | export default LandingPage 79 | -------------------------------------------------------------------------------- /src/components/Workspace.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Box from '@material-ui/core/Box'; 5 | 6 | import { LiveUpdate } from "@solid/react"; 7 | import {Switch, Route} from 'react-router-dom' 8 | 9 | import WorkspaceContext, {WorkspaceProvider} from "../context/workspace"; 10 | 11 | import WorkspaceDrawer from './WorkspaceDrawer'; 12 | import CurrentPage from "./CurrentPage" 13 | import CurrentConcept from "./CurrentConcept" 14 | import Home from "./Home" 15 | import PublicProfile, {EncodedWebIdPublicProfile} from './PublicProfile'; 16 | import { drawerWidth } from '../constants' 17 | 18 | const useStyles = makeStyles(theme => ({ 19 | content: { 20 | marginLeft: drawerWidth, 21 | height: "100%", 22 | position: "relative" 23 | }, 24 | })); 25 | 26 | 27 | const WorkspaceRoute = ({children, ...props}) => { 28 | const {workspace} = useContext(WorkspaceContext); 29 | return ( 30 | 31 | {workspace && ( 32 | 33 | 34 | 35 | )} 36 | {children} 37 | 38 | ) 39 | } 40 | 41 | 42 | function WorkspaceContent(){ 43 | const classes = useStyles() 44 | return ( 45 | <> 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ) 67 | } 68 | 69 | export default function Workspace(){ 70 | return ( 71 | 72 | 73 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/components/edit/Block.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, ReactNode, useRef, useState } from 'react' 2 | import { Element, Transforms } from 'slate'; 3 | import { useEditor, useReadOnly, ReactEditor } from 'slate-react'; 4 | import { useDrag, useDrop } from 'react-dnd' 5 | 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import Menu, { MenuProps } from '@material-ui/core/Menu'; 8 | import MenuItem from '@material-ui/core/MenuItem'; 9 | 10 | import AddIcon from '@material-ui/icons/Add'; 11 | import DragIcon from '@material-ui/icons/DragIndicator'; 12 | 13 | import IconButton from '../IconButton'; 14 | import InsertMenu from './InsertMenu' 15 | import TurnIntoMenu from './TurnIntoMenu' 16 | 17 | export const useStyles = makeStyles(theme => ({ 18 | dragButton: { 19 | cursor: ({ isDragging }: { isDragging?: boolean }) => isDragging ? "move" : "grab" 20 | }, 21 | block: { 22 | position: "relative", 23 | "&:hover $blockHoverButtons": { 24 | visibility: "visible" 25 | }, 26 | left: -theme.spacing(7), 27 | paddingLeft: theme.spacing(7), 28 | borderBottomWidth: theme.spacing(0.5), 29 | borderBottomStyle: "solid", 30 | borderBottomColor: "transparent" 31 | }, 32 | blockHoverButtons: { 33 | visibility: ({ menuOpen }: { menuOpen?: boolean }) => menuOpen ? "visible" : "hidden", 34 | opacity: 0.5, 35 | }, 36 | blockContent: { 37 | borderBottomWidth: theme.spacing(0.5), 38 | borderBottomStyle: "solid", 39 | borderBottomColor: ({ isOver }: { isOver?: boolean }) => isOver ? theme.palette.info.light : "transparent" 40 | }, 41 | blockButtons: { 42 | position: "absolute", 43 | left: -theme.spacing(0), 44 | "& button": { 45 | padding: 0 46 | } 47 | }, 48 | })) 49 | 50 | type BlockMenuProps = MenuProps & { element: Element, onClose: () => void } 51 | 52 | const BlockMenu: FunctionComponent = ({ element, onClose, ...props }) => { 53 | const editor = useEditor() 54 | const turnIntoRef = useRef(null) 55 | const [turnIntoMenuOpen, setTurnIntoMenuOpen] = useState(false) 56 | return ( 57 | <> 58 | { 69 | setTurnIntoMenuOpen(false) 70 | onClose() 71 | }} 72 | {...props}> 73 | { 74 | Transforms.removeNodes(editor, { 75 | at: ReactEditor.findPath(editor, element) 76 | }) 77 | }}> 78 | delete 79 | 80 | setTurnIntoMenuOpen(true)} 82 | onMouseLeave={() => setTurnIntoMenuOpen(false)} 83 | > 84 | turn into ⩺ 85 | 86 | 87 | {turnIntoMenuOpen && ( 88 | setTurnIntoMenuOpen(true)} 90 | anchorEl={turnIntoRef.current} 91 | open={turnIntoMenuOpen} onClose={() => { 92 | setTurnIntoMenuOpen(false) 93 | onClose() 94 | }} /> 95 | )} 96 | 97 | ) 98 | } 99 | 100 | type BlockProps = { 101 | children: ReactNode, 102 | element: Element 103 | } 104 | 105 | const Block: FunctionComponent = ({ children, element }) => { 106 | const editor = useEditor() 107 | const readOnly = useReadOnly() 108 | const buttonsRef = useRef(null) 109 | const [menuOpen, setMenuOpen] = useState(false) 110 | const insertRef = useRef(null) 111 | const [insertMenuOpen, setInsertMenuOpen] = useState(false) 112 | const [{ isDragging }, drag, preview] = useDrag({ 113 | item: { type: "block", element }, 114 | collect: monitor => ({ 115 | isDragging: !!monitor.isDragging(), 116 | }) 117 | }) 118 | const [{ isOver }, drop] = useDrop({ 119 | accept: "block", 120 | drop: (item: any) => { 121 | const sourcePath = ReactEditor.findPath(editor, item.element) 122 | const sourceIndex = sourcePath[sourcePath.length - 1] 123 | const targetPath = ReactEditor.findPath(editor, element) 124 | const targetIndex = targetPath[targetPath.length - 1] 125 | if (sourceIndex !== targetIndex) { 126 | const insertIndex = sourceIndex > targetIndex ? targetIndex + 1 : targetIndex 127 | Transforms.moveNodes(editor, { 128 | at: sourcePath, 129 | to: [...targetPath.slice(0, -1), insertIndex] 130 | }) 131 | } 132 | }, 133 | collect: monitor => ({ 134 | isOver: !!monitor.isOver(), 135 | }), 136 | }) 137 | 138 | const classes = useStyles({ menuOpen, isDragging, isOver }) 139 | 140 | return ( 141 |
    142 | {!readOnly && ( 143 | <> 144 | setMenuOpen(false)} /> 147 | { 150 | setInsertMenuOpen(false) 151 | }} 152 | onExiting={() => { 153 | ReactEditor.focus(editor) 154 | }} /> 155 |
    156 | setInsertMenuOpen(!insertMenuOpen)} 158 | title="insert"> 159 | 160 | 161 | setMenuOpen(!menuOpen)} className={classes.dragButton} 163 | title=""> 164 | 165 | 166 |
    167 | 168 | )} 169 |
    170 | {children} 171 |
    172 |
    173 | ) 174 | } 175 | 176 | export default Block 177 | -------------------------------------------------------------------------------- /src/components/edit/ConceptElement.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useContext, FunctionComponent } from 'react' 2 | import { Transforms, Range, Element } from 'slate'; 3 | import { useSelected, useEditor } from 'slate-react'; 4 | 5 | import copy from 'copy-to-clipboard'; 6 | 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import TextField from '@material-ui/core/TextField'; 9 | import Popover, { PopoverProps } from '@material-ui/core/Popover'; 10 | import SaveIcon from '@material-ui/icons/Save'; 11 | import EditIcon from '@material-ui/icons/Edit'; 12 | import UnlinkIcon from '@material-ui/icons/LinkOff'; 13 | import CopyIcon from '@material-ui/icons/FileCopy'; 14 | 15 | import IconButton from '../IconButton'; 16 | import Link from '../Link'; 17 | import { setConceptProps, removeConcept } from '../../utils/editor'; 18 | import { ElementProps } from "./" 19 | import { conceptPath, conceptUri, conceptUrl } from '../../utils/urls' 20 | import WorkspaceContext from '../../context/workspace' 21 | 22 | const useStyles = makeStyles(theme => ({ 23 | aPopover: { 24 | padding: theme.spacing(1) 25 | }, 26 | linkPopupButton: { 27 | padding: 0, 28 | marginLeft: theme.spacing(1) 29 | }, 30 | })) 31 | 32 | type ConceptPopoverProps = { 33 | element: Element, 34 | editing: boolean, 35 | setEditing: (editing: boolean) => void, 36 | onClose: () => void 37 | } & PopoverProps 38 | 39 | const ConceptPopover: FunctionComponent = ({ element, editing, setEditing, onClose, ...props }) => { 40 | const { workspace } = useContext(WorkspaceContext) 41 | const editor = useEditor() 42 | const [selection, setSelection] = useState(null) 43 | const [editValue, setEditValue] = useState(element.name) 44 | const classes = useStyles() 45 | const editLink = () => { 46 | setSelection(editor.selection) 47 | setEditing(true) 48 | } 49 | const saveLink = () => { 50 | workspace && setConceptProps(editor, element, editValue, conceptUri(workspace.conceptContainerUri, editValue)) 51 | onClose() 52 | setEditing(false) 53 | selection && Transforms.select(editor, selection) 54 | } 55 | return ( 56 | 68 | {editing ? ( 69 | setEditValue(e.target.value)} 70 | onKeyDown={event => { 71 | if (event.keyCode === 13) { 72 | event.preventDefault() 73 | saveLink() 74 | } 75 | }} /> 76 | ) : ( 77 | {element.name} 78 | )} 79 | {editing ? ( 80 | 82 | 83 | 84 | ) : ( 85 | 87 | 88 | 89 | )} 90 | removeConcept(editor)}> 92 | 93 | 94 | copy(conceptUrl(element.uri))}> 96 | 97 | 98 | 99 | ) 100 | } 101 | 102 | const ConceptElement: FunctionComponent = ({ attributes, children, element }) => { 103 | const selected = useSelected() 104 | const editor = useEditor() 105 | const selectionCollapsed = !!(editor.selection && Range.isCollapsed(editor.selection)) 106 | const aRef = useRef(null) 107 | const [editingLink, setEditingLink] = useState(false) 108 | const open = (!!aRef.current) && (editingLink || (selected && selectionCollapsed)) 109 | return ( 110 | <> 111 | 112 | {children} 113 | 114 | {open && ( 115 | { 117 | setEditingLink(false) 118 | }} 119 | editing={editingLink} setEditing={setEditingLink} /> 120 | )} 121 | 122 | ) 123 | } 124 | 125 | export default ConceptElement 126 | -------------------------------------------------------------------------------- /src/components/edit/ImageElement.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, FunctionComponent } from 'react' 2 | import { Transforms } from 'slate'; 3 | import { useSelected, useFocused, useEditor, ReactEditor } from 'slate-react'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Box from '@material-ui/core/Box'; 7 | import EditIcon from '@material-ui/icons/Edit'; 8 | import ArrowRight from '@material-ui/icons/ArrowRight'; 9 | 10 | import { ImageEditor } from '../ImageUploader'; 11 | 12 | import { ElementProps } from "./" 13 | import { useStyles as useBlockStyles } from "./Block" 14 | 15 | const useStyles = makeStyles(theme => ({ 16 | image: { 17 | display: "block", 18 | width: ({ width }: { width?: number }) => width || theme.spacing(20), 19 | height: "auto", 20 | boxShadow: ({ selected, focused }: { selected?: boolean, focused?: boolean }) => 21 | selected && focused ? '0 0 0 3px #B4D5FF' : 'none' 22 | }, 23 | editImageIcon: { 24 | cursor: "pointer" 25 | }, 26 | imageWidthDragHandle: { 27 | width: theme.spacing(3), 28 | cursor: "ew-resize", 29 | background: "transparent", 30 | display: "flex", 31 | flexDirection: "column", 32 | justifyContent: "center" 33 | } 34 | })) 35 | 36 | const ImageElement: FunctionComponent = ({ attributes, children, element }) => { 37 | const editor = useEditor() 38 | const image = useRef(null) 39 | const [editing, setEditing] = useState(false) 40 | const [dragStart, setDragStart] = useState(null) 41 | const [dragStartImageWidth, setDragStartImageWidth] = useState(null) 42 | const selected = useSelected() 43 | const focused = useFocused() 44 | const width = element.width; 45 | const classes = useStyles({ selected, focused, width }) 46 | const blockClasses = useBlockStyles() 47 | const path = ReactEditor.findPath(editor, element) 48 | return ( 49 |
    50 | 51 | {element.alt 56 | 60 | setEditing(true)} /> 63 | { 67 | Transforms.select(editor, path) 68 | setDragStartImageWidth(image && image.current && image.current.clientWidth) 69 | setDragStart(e.clientX) 70 | }} 71 | onDrag={e => { 72 | if (dragStartImageWidth && dragStart) { 73 | const newWidth = dragStartImageWidth + (e.clientX - dragStart) 74 | if (width !== newWidth) { 75 | Transforms.setNodes(editor, { width: newWidth }, { at: path }) 76 | } 77 | } 78 | }}> 79 | 80 | 81 | 82 | 83 | {children} 84 | setEditing(false)} 86 | onSave={(savedUrl: string) => { 87 | const u = new URL(savedUrl) 88 | u.searchParams.set("updated", Date.now().toString()) 89 | Transforms.setNodes(editor, { url: u.toString() }, { at: path }) 90 | setEditing(false) 91 | }} /> 92 |
    93 | ) 94 | } 95 | 96 | export default ImageElement 97 | -------------------------------------------------------------------------------- /src/components/edit/InsertMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, forwardRef, useCallback, Ref, FunctionComponent, PropsWithChildren } from 'react'; 2 | import { Transforms, Element } from 'slate'; 3 | import { useEditor } from 'slate-react'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Menu, { MenuProps } from '@material-ui/core/Menu'; 7 | import MenuItem from '@material-ui/core/MenuItem'; 8 | 9 | import { insertBlock, insertionPoint } from '../../utils/editor'; 10 | import DocumentContext from '../../context/document' 11 | 12 | import EmbedPicker from '../EmbedPicker' 13 | import ImageUploader from '../ImageUploader' 14 | 15 | 16 | const useStyles = makeStyles(theme => ({ 17 | imageUploadPopover: { 18 | minWidth: theme.spacing(30), 19 | minHeight: theme.spacing(20) 20 | } 21 | })) 22 | 23 | type InsertItemProps = PropsWithChildren<{ 24 | element: Element, 25 | format: string, 26 | onClose: () => void 27 | }> 28 | 29 | const InsertItem: FunctionComponent = forwardRef(({ element, format, onClose, ...props }, ref: Ref) => { 30 | const editor = useEditor() 31 | const onClick = useCallback(async () => { 32 | const insertAt = insertionPoint(editor, element) 33 | insertBlock(editor, format, insertAt) 34 | Transforms.select(editor, insertAt) 35 | onClose() 36 | }, [editor, format, element, onClose]) 37 | return ( 38 | 39 | ) 40 | }) 41 | 42 | type InsertImageItemProps = PropsWithChildren<{ 43 | element: Element, 44 | onClose: () => void 45 | }> 46 | 47 | const InsertImageItem: FunctionComponent = forwardRef(({ element, onClose, ...props }, ref: Ref) => { 48 | const classes = useStyles() 49 | const document = useContext(DocumentContext) 50 | const [imagePickerOpen, setImagePickerOpen] = useState(false) 51 | return ( 52 | <> 53 | setImagePickerOpen(true)} ref={ref} {...props} /> 54 | {document && ( 55 | 60 | )} 61 | 62 | ) 63 | }) 64 | 65 | type InsertEmbedItemProps = PropsWithChildren<{ 66 | element: Element, 67 | onClose: () => void 68 | }> 69 | 70 | const InsertEmbedItem: FunctionComponent = forwardRef(({ element, onClose, ...props }, ref: Ref) => { 71 | const [embedPickerOpen, setEmbedPickerOpen] = useState(false) 72 | const editor = useEditor() 73 | 74 | const close = useCallback(() => { 75 | setEmbedPickerOpen(false) 76 | onClose() 77 | }, [onClose]) 78 | const save = useCallback(async (embedUrl, embedType) => { 79 | const insertAt = insertionPoint(editor, element) 80 | insertBlock(editor, 'embed', insertAt, { url: embedUrl, embedType }) 81 | Transforms.select(editor, insertAt) 82 | onClose() 83 | }, [editor, element, onClose]) 84 | return ( 85 | <> 86 | setEmbedPickerOpen(true)} ref={ref} {...props} /> 87 | 92 | 93 | ) 94 | }) 95 | 96 | type InsertMenuProps = MenuProps & { 97 | element: Element, 98 | onClose: () => void 99 | } 100 | 101 | const InsertMenu: FunctionComponent = ({ element, onClose, ...props }) => { 102 | return ( 103 | 115 | 116 | text 117 | 118 | 119 | heading 1 120 | 121 | 122 | heading 2 123 | 124 | 125 | heading 3 126 | 127 | 128 | quote 129 | 130 | 131 | numbered list 132 | 133 | 134 | bulleted list 135 | 136 | 137 | todo list 138 | 139 | 140 | image 141 | 142 | 143 | table 144 | 145 | 146 | embed 147 | 148 | ) 149 | } 150 | 151 | export default InsertMenu 152 | -------------------------------------------------------------------------------- /src/components/edit/LinkElement.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, FunctionComponent } from 'react' 2 | import { Transforms, Range, Element } from 'slate'; 3 | import { useSelected, useEditor } from 'slate-react'; 4 | 5 | import copy from 'copy-to-clipboard'; 6 | 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import Link from '@material-ui/core/Link'; 9 | import TextField from '@material-ui/core/TextField'; 10 | import Popover, { PopoverProps } from '@material-ui/core/Popover'; 11 | import SaveIcon from '@material-ui/icons/Save'; 12 | import EditIcon from '@material-ui/icons/Edit'; 13 | import UnlinkIcon from '@material-ui/icons/LinkOff'; 14 | import CopyIcon from '@material-ui/icons/FileCopy'; 15 | 16 | import IconButton from '../IconButton'; 17 | import { setLinkUrl, removeLink } from '../../utils/editor'; 18 | import { ElementProps } from "./" 19 | 20 | const useStyles = makeStyles(theme => ({ 21 | aPopover: { 22 | padding: theme.spacing(1) 23 | }, 24 | linkPopupButton: { 25 | padding: 0, 26 | marginLeft: theme.spacing(1) 27 | }, 28 | })) 29 | 30 | type LinkPopoverProps = { 31 | element: Element, 32 | editing: boolean, 33 | setEditing: (editing: boolean) => void, 34 | onClose: () => void 35 | } & PopoverProps 36 | 37 | const LinkPopover: FunctionComponent = ({ element, editing, setEditing, onClose, ...props }) => { 38 | const editor = useEditor() 39 | const [selection, setSelection] = useState(null) 40 | const [editValue, setEditValue] = useState(element.url) 41 | const classes = useStyles() 42 | const editLink = () => { 43 | setSelection(editor.selection) 44 | setEditing(true) 45 | } 46 | const saveLink = () => { 47 | setLinkUrl(editor, element, editValue) 48 | onClose() 49 | setEditing(false) 50 | selection && Transforms.select(editor, selection) 51 | } 52 | return ( 53 | 65 | {editing ? ( 66 | setEditValue(e.target.value)} 67 | onKeyDown={event => { 68 | if (event.keyCode === 13) { 69 | event.preventDefault() 70 | saveLink() 71 | } 72 | }} /> 73 | ) : ( 74 | {element.url} 75 | )} 76 | {editing ? ( 77 | 79 | 80 | 81 | ) : ( 82 | 84 | 85 | 86 | )} 87 | removeLink(editor)}> 89 | 90 | 91 | copy(element.url)}> 93 | 94 | 95 | 96 | ) 97 | } 98 | 99 | const LinkElement: FunctionComponent = ({ attributes, children, element }) => { 100 | const selected = useSelected() 101 | const editor = useEditor() 102 | const selectionCollapsed = !!(editor.selection && Range.isCollapsed(editor.selection)) 103 | const aRef = useRef(null) 104 | const [editingLink, setEditingLink] = useState(false) 105 | const open = (!!aRef.current) && (editingLink || (selected && selectionCollapsed)) 106 | return ( 107 | <> 108 | 109 | {children} 110 | 111 | {open && ( 112 | { 114 | setEditingLink(false) 115 | }} 116 | editing={editingLink} setEditing={setEditingLink} /> 117 | )} 118 | 119 | ) 120 | } 121 | 122 | export default LinkElement 123 | -------------------------------------------------------------------------------- /src/components/edit/Table.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | import { useEditor } from 'slate-react'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Box from '@material-ui/core/Box'; 7 | import ArrowRight from '@material-ui/icons/ArrowRight'; 8 | import ArrowDown from '@material-ui/icons/ArrowDropDown'; 9 | import ArrowLeft from '@material-ui/icons/ArrowLeft'; 10 | import ArrowUp from '@material-ui/icons/ArrowDropUp'; 11 | 12 | import { 13 | insertRow, insertColumn, removeRow, removeColumn, 14 | } from '../../utils/editor'; 15 | 16 | import { ElementProps } from "./" 17 | import IconButton from '../IconButton'; 18 | import { useStyles as useBlockStyles } from './Block' 19 | 20 | const useStyles = makeStyles(theme => ({ 21 | table: { 22 | border: "1px solid black", 23 | borderCollapse: "collapse" 24 | }, 25 | tr: { 26 | }, 27 | td: { 28 | border: `2px solid ${theme.palette.grey[300]}`, 29 | padding: theme.spacing(1) 30 | }, 31 | columnButtons: { 32 | display: "flex", 33 | flexDirection: "column", 34 | verticalAlign: "top" 35 | }, 36 | rowButtons: { 37 | display: "flex" 38 | }, 39 | })) 40 | 41 | 42 | 43 | const Table: FunctionComponent = ({ attributes, children, element }) => { 44 | const editor = useEditor() 45 | const classes = useStyles() 46 | const blockClasses = useBlockStyles() 47 | return ( 48 | <> 49 | 50 | 51 | {children} 52 |
    53 | 55 | insertColumn(editor, element)}> 57 | 58 | 59 | removeColumn(editor, element)}> 61 | 62 | 63 | 64 |
    65 | 66 | insertRow(editor, element)}> 68 | 69 | 70 | removeRow(editor, element)}> 72 | 73 | 74 | 75 | 76 | ) 77 | } 78 | export default Table 79 | -------------------------------------------------------------------------------- /src/components/edit/TurnIntoMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { Ref, FunctionComponent, forwardRef, useCallback } from 'react'; 2 | import { Element } from 'slate'; 3 | import { useEditor, ReactEditor } from 'slate-react'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Menu, { MenuProps } from '@material-ui/core/Menu'; 7 | import MenuItem from '@material-ui/core/MenuItem'; 8 | 9 | import { makeBlock } from '../../utils/editor'; 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | turnIntoMenu: { 13 | pointerEvents: "none" 14 | }, 15 | turnIntoMenuPaper: { 16 | pointerEvents: "auto" 17 | }, 18 | })) 19 | 20 | type TurnIntoItemProps = { 21 | element: Element, 22 | format: string, 23 | onClose: () => void 24 | } 25 | 26 | const TurnIntoItem: FunctionComponent = forwardRef(({ element, format, onClose, ...props }, ref: Ref) => { 27 | const editor = useEditor() 28 | const onClick = useCallback(() => { 29 | makeBlock(editor, format, ReactEditor.findPath(editor, element)) 30 | onClose() 31 | }, [editor, format, element, onClose]) 32 | return ( 33 | 34 | ) 35 | }) 36 | 37 | type TurnIntoMenuProps = MenuProps & { 38 | element: Element, 39 | onClose: () => void 40 | } 41 | 42 | const TurnIntoMenu: FunctionComponent = ({ element, onClose, ...props }) => { 43 | const classes = useStyles() 44 | return ( 45 | 60 | 61 | text 62 | 63 | 64 | heading 1 65 | 66 | 67 | heading 2 68 | 69 | 70 | heading 3 71 | 72 | 73 | quote 74 | 75 | 76 | numbered list 77 | 78 | 79 | bulleted list 80 | 81 | 82 | todo list 83 | 84 | ) 85 | } 86 | 87 | export default TurnIntoMenu 88 | -------------------------------------------------------------------------------- /src/components/edit/index.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { Element } from 'slate'; 3 | 4 | 5 | export type ElementProps = { 6 | attributes: { [key: string]: any }, 7 | element: Element, 8 | children: ReactNode 9 | } 10 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const drawerWidth = 240; 2 | -------------------------------------------------------------------------------- /src/context/auth.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | import auth, { Session } from 'solid-auth-client'; 3 | import { useHistory } from "react-router-dom"; 4 | 5 | type AuthProviderContextType = { 6 | logIn: () => Promise, 7 | logOut: () => Promise 8 | } 9 | 10 | async function logIn() { 11 | return await auth.popupLogin({ popupUri: "/popup.html" }) 12 | } 13 | async function logOut() { 14 | return await auth.logout() 15 | } 16 | 17 | const AuthContext = createContext({ logIn, logOut }) 18 | 19 | const { Provider } = AuthContext; 20 | 21 | export const AuthProvider = (props: any) => { 22 | const history = useHistory() 23 | 24 | async function logOutAndGoHome() { 25 | await logOut() 26 | history.push("/") 27 | } 28 | 29 | async function logInAndGoHome() { 30 | await logIn() 31 | history.push("/") 32 | } 33 | return ( 34 | 35 | ) 36 | } 37 | 38 | export const useAuthContext = () => useContext(AuthContext) 39 | 40 | export default AuthContext 41 | -------------------------------------------------------------------------------- /src/context/document.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { Document } from '../utils/model' 3 | 4 | export default createContext(null) 5 | -------------------------------------------------------------------------------- /src/context/preferences.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from 'react'; 2 | 3 | export interface Preferences { 4 | devMode: boolean 5 | setDevMode: (v: boolean) => void 6 | } 7 | 8 | var devMode = false 9 | const setDevMode = (m: boolean) => { } 10 | 11 | const PreferencesContext = createContext({ devMode, setDevMode }) 12 | 13 | const { Provider } = PreferencesContext 14 | 15 | export const usePreferences = () => useContext(PreferencesContext) 16 | 17 | export const PreferencesProvider = (props: any) => { 18 | const [devMode, setDevMode] = useState(false) 19 | return ( 20 | 21 | ) 22 | } 23 | 24 | export default PreferencesContext; 25 | -------------------------------------------------------------------------------- /src/context/workspace.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, createContext, useCallback, useEffect, useMemo } from 'react'; 2 | import { space, schema, dct } from 'rdf-namespaces'; 3 | import { useWebId } from '@solid/react'; 4 | import data from '@solid/query-ldflex'; 5 | import { namedNode } from '@rdfjs/data-model'; 6 | import { createNonExistentDocument } from '../utils/ldflex-helper'; 7 | import { createDefaultAcl } from '../utils/acl'; 8 | import { listResolver, listValuesResolver } from '../utils/data'; 9 | import { useValueQuery } from '../hooks/data'; 10 | import * as m from "../utils/model" 11 | 12 | type AddPageType = (props: m.PageProps, pageListProps: m.PageListItemProps) => Promise 13 | type AddConceptType = (props: m.ConceptProps) => Promise 14 | type AddSubPageType = (props: m.PageProps, parentPageListItem: m.PageListItem) => Promise 15 | type UpdateTextType = (document: m.Document, value: string, conceptUris: string[]) => Promise 16 | type UpdateNameType = (page: m.Page, value: any) => Promise 17 | type DeleteDocumentType = (document: m.Document) => Promise 18 | 19 | export interface WorkspaceContextType { 20 | conceptContainer?: string, 21 | publicPages?: string, 22 | workspace?: m.Workspace, 23 | addPage?: AddPageType, 24 | addConcept?: AddConceptType, 25 | addSubPage?: AddSubPageType, 26 | updateText?: UpdateTextType, 27 | updateName?: UpdateNameType, 28 | deleteDocument?: DeleteDocumentType 29 | } 30 | 31 | const WorkspaceContext = createContext({}); 32 | 33 | const { Provider } = WorkspaceContext; 34 | 35 | type WorkspaceProviderProps = { 36 | children: ReactNode 37 | } 38 | 39 | export const WorkspaceProvider = ({ children }: WorkspaceProviderProps) => { 40 | const webId = useWebId(); 41 | const [storage] = useValueQuery(webId, space.storage) 42 | const workspace = useMemo( 43 | () => (storage === undefined) ? undefined : m.workspaceFromStorage(storage), 44 | [storage] 45 | ) 46 | 47 | useEffect(() => { 48 | if (workspace && workspace.docUri) { 49 | const createWorkspace = async () => { 50 | Promise.all([ 51 | createNonExistentDocument(workspace.docUri), 52 | createDefaultAcl(webId, workspace.containerUri) 53 | ]) 54 | } 55 | createWorkspace(); 56 | } 57 | }, [workspace, webId]) 58 | 59 | const addPage: AddPageType = async ({ name = "Untitled" }, pageListItemProps) => { 60 | if (workspace !== undefined) { 61 | return await m.addPage(workspace, { name }, pageListItemProps) 62 | } else { 63 | return null 64 | } 65 | } 66 | 67 | const addConcept: AddConceptType = async ({ name }) => { 68 | if (workspace !== undefined) { 69 | return await m.addConcept(workspace, name) 70 | } else { 71 | return null 72 | } 73 | } 74 | 75 | const addSubPage: AddSubPageType = async ({ name = "Untitled" }, parentPageListItem) => { 76 | const subPageList = await listResolver(data[parentPageListItem.pageUri][schema.itemListElement]) 77 | return await m.addSubPage(parentPageListItem, { name }, { position: subPageList.length }) 78 | } 79 | 80 | const updateName = useCallback(async (page: m.Page, value: string) => { 81 | await Promise.all([ 82 | data[page.uri][schema.name].set(value), 83 | data[page.inListItem][schema.name].set(value), 84 | data.from(page.metaUri)[page.uri][schema.name].set(value) 85 | ]) 86 | }, []) 87 | 88 | const updateText = useCallback(async (doc: m.Document, value: string, conceptUris: string[]) => { 89 | const conceptUrisSet = new Set(conceptUris) 90 | const references: string[] = await listValuesResolver(data[doc.uri][dct.references]) 91 | const referencesSet = new Set(references) 92 | const toAdd = conceptUris.filter(x => !referencesSet.has(x)) 93 | const toDelete = references.filter(x => !conceptUrisSet.has(x)) 94 | if (workspace) { 95 | await m.setDocumentText(workspace, doc, value, toAdd, toDelete) 96 | } 97 | 98 | }, [workspace]) 99 | 100 | const deleteDocument = useCallback(async (document: m.Document) => { 101 | await data[document.parentUri][schema.itemListElement].delete(namedNode(document.inListItem)) 102 | }, []) 103 | 104 | return ( 105 | 108 | ) 109 | } 110 | 111 | export default WorkspaceContext; 112 | -------------------------------------------------------------------------------- /src/hooks/acls.ts: -------------------------------------------------------------------------------- 1 | import solid from 'solid-auth-client'; 2 | import { useState, useEffect } from 'react'; 3 | import { useLiveUpdate } from '@solid/react'; 4 | import { getAccessInfo, getParentACLUri, AccessInfo } from "../utils/acl" 5 | 6 | export function useAccessInfo(documentUri: string) { 7 | const [error, setError] = useState(undefined); 8 | const [accessInfo, setAccessInfo] = useState({}); 9 | const [loading, setLoading] = useState(false); 10 | useEffect(() => { 11 | if (documentUri) { 12 | const fetchUri = async () => { 13 | setLoading(true) 14 | try { 15 | setAccessInfo(await getAccessInfo(documentUri)) 16 | } catch (e) { 17 | setError(e) 18 | } 19 | setLoading(false) 20 | } 21 | fetchUri() 22 | } 23 | }, [documentUri]) 24 | return { loading, error, ...accessInfo } 25 | } 26 | 27 | export function useAclExists(aclUri: string) { 28 | const [error, setError] = useState(undefined); 29 | const [exists, setExists] = useState({}); 30 | const [loading, setLoading] = useState(false); 31 | const { timestamp, url } = useLiveUpdate() 32 | const [thisTimestamp, setThisTimestamp] = useState(timestamp) 33 | if ((timestamp !== thisTimestamp) && (url === aclUri)) { 34 | setThisTimestamp(timestamp) 35 | } 36 | useEffect(() => { 37 | if (aclUri) { 38 | const fetchUri = async () => { 39 | setLoading(true) 40 | try { 41 | const response = await solid.fetch(aclUri, { method: 'HEAD' }) 42 | setExists(response.ok) 43 | } catch (e) { 44 | setError(e) 45 | } 46 | setLoading(false) 47 | } 48 | fetchUri() 49 | } 50 | }, [aclUri, timestamp]) 51 | return { loading, error, exists } 52 | } 53 | 54 | export function useParentAcl(pageUri: string) { 55 | const [error, setError] = useState(); 56 | const [aclUri, setAclUri] = useState(); 57 | const [loading, setLoading] = useState(false); 58 | useEffect(() => { 59 | if (pageUri) { 60 | const fetchUri = async () => { 61 | setLoading(true) 62 | try { 63 | setAclUri(await getParentACLUri(pageUri)) 64 | } catch (e) { 65 | setError(e) 66 | } 67 | setLoading(false) 68 | } 69 | fetchUri() 70 | } 71 | }, [pageUri]) 72 | return { loading, error, uri: aclUri } 73 | } 74 | -------------------------------------------------------------------------------- /src/hooks/backup.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, MutableRefObject } from 'react' 2 | import { schema, dct } from 'rdf-namespaces'; 3 | import solidNamespace from 'solid-namespace'; 4 | import data from '@solid/query-ldflex'; 5 | import { namedNode, literal } from '@rdfjs/data-model'; 6 | import { Node } from 'slate' 7 | import { resourceExists, createNonExistentDocument } from '../utils/ldflex-helper' 8 | import { backupFolderForPage } from '../utils/backups' 9 | import concept from '../ontology' 10 | import { Document } from '../utils/model' 11 | 12 | const ns = solidNamespace() 13 | 14 | const EVERY_MINUTE = 60 * 1000 15 | const EVERY_FIVE_MINUTES = 5 * EVERY_MINUTE 16 | const EVERY_TEN_MINUTES = 10 * EVERY_MINUTE 17 | const EVERY_THIRTY_MINUTES = 30 * EVERY_MINUTE 18 | 19 | type BodyRef = MutableRefObject 20 | 21 | async function ensureBackupFileExists(pageUri: string, backup: string) { 22 | const exists = await resourceExists(backup) 23 | if (!exists) { 24 | await createNonExistentDocument(backup) 25 | } 26 | const backupOf = data[backup][concept.backupOf] 27 | if (!await backupOf) { 28 | await backupOf.set(namedNode(pageUri)) 29 | } 30 | } 31 | 32 | async function ensureBackupFolderExists(backupFolder: string) { 33 | const metaFile = `${backupFolder}.meta` 34 | const exists = await resourceExists(metaFile) 35 | if (!exists) { 36 | await createNonExistentDocument(metaFile) 37 | } 38 | } 39 | 40 | export async function createBackup(pageUri: string, backupFile: string, value: string) { 41 | const folder = backupFolderForPage(pageUri) 42 | const metaFile = `${folder}.meta` 43 | const backup = `${folder}${backupFile}` 44 | await ensureBackupFileExists(pageUri, backup) 45 | await data[backup][schema.text].set(value) 46 | await data[backup][dct.modified].set(literal(new Date().toISOString(), ns.xsd("dateTime"))) 47 | await data.from(metaFile)[backup][dct.modified].set(literal(new Date().toISOString(), ns.xsd("dateTime"))) 48 | } 49 | 50 | function createBackupInterval(bodyRef: BodyRef, page: Document, backupFile: string, interval: number) { 51 | return setInterval(async () => { 52 | if (bodyRef.current !== undefined) { 53 | await createBackup(page.uri, backupFile, JSON.stringify(bodyRef.current)) 54 | } 55 | }, interval) 56 | } 57 | 58 | export function useBackups(page: Document, value: Node[] | undefined) { 59 | const bodyRef: BodyRef = useRef() 60 | bodyRef.current = value 61 | useEffect(() => { 62 | const backupFolder = backupFolderForPage(page.uri) 63 | ensureBackupFolderExists(backupFolder) 64 | const one = createBackupInterval(bodyRef, page, `oneMinute.ttl`, EVERY_MINUTE) 65 | const five = createBackupInterval(bodyRef, page, `fiveMinutes.ttl`, EVERY_FIVE_MINUTES) 66 | const ten = createBackupInterval(bodyRef, page, `tenMinutes.ttl`, EVERY_TEN_MINUTES) 67 | const thirty = createBackupInterval(bodyRef, page, `thirtyMinutes.ttl`, EVERY_THIRTY_MINUTES) 68 | return () => { 69 | clearInterval(one) 70 | clearInterval(five) 71 | clearInterval(ten) 72 | clearInterval(thirty) 73 | } 74 | }, [page, bodyRef]) 75 | } 76 | -------------------------------------------------------------------------------- /src/hooks/concepts.ts: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import { useConcept } from "./data" 3 | import { Concept } from "../utils/model" 4 | 5 | export function useCurrentConcept(): [Concept | undefined, boolean, Error | undefined] { 6 | const { selectedConcept } = useParams(); 7 | return useConcept(selectedConcept ? decodeURIComponent(selectedConcept) : undefined) 8 | } 9 | 10 | export function useCurrentConceptUri(): string | undefined { 11 | const { selectedConcept } = useParams(); 12 | return selectedConcept ? decodeURIComponent(selectedConcept) : undefined 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/data.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useMemo } from 'react'; 2 | import { useWebId, useLiveUpdate } from '@solid/react'; 3 | import { space, schema } from 'rdf-namespaces'; 4 | import { appContainerUrl } from '../utils/urls'; 5 | import { 6 | Resolver, dateResolver, valueResolver, listResolver, listValuesResolver, 7 | pageListItemsResolver, conceptListItemsResolver, 8 | pageResolver, conceptResolver, documentResolver 9 | } from '../utils/data'; 10 | import data from '@solid/query-ldflex'; 11 | import { Document, Concept, Page, PageContainer, PageListItem, ConceptContainer, ConceptListItem } from '../utils/model' 12 | 13 | export function useAppContainer() { 14 | const webId = useWebId(); 15 | const [storage] = useValueQuery(webId, space.storage) 16 | return storage && appContainerUrl(storage) 17 | } 18 | 19 | export function useWorkspace() { 20 | const appContainer = useAppContainer() 21 | const workspace = useMemo(() => { 22 | const workspaceContainer = appContainer && `${appContainer}workspace/` 23 | const docUri = workspaceContainer && `${workspaceContainer}index.ttl` 24 | return docUri && { 25 | docUri, 26 | uri: `${docUri}#Workspace`, 27 | containerUri: workspaceContainer, 28 | subpageContainerUri: `${workspaceContainer}pages/` 29 | } 30 | }, [appContainer]); 31 | return workspace 32 | } 33 | 34 | type QueryTerm = string | undefined 35 | type UseQueryResult = [T | undefined, boolean, Error | undefined] 36 | type QueryOptions = { source?: string, skip?: boolean, resolver?: Resolver } 37 | 38 | const defaultResolver: Resolver = async (query: any) => query 39 | 40 | function useQuery(subject: QueryTerm, predicate: QueryTerm | null, { source = subject, skip = false, resolver = defaultResolver }: QueryOptions): UseQueryResult { 41 | const { url: updatedUri, timestamp } = useLiveUpdate() 42 | const [updatedTimestamp, setUpdatedTimestamp] = useState(timestamp) 43 | useEffect(() => { 44 | if (!skip && source) { 45 | try { 46 | const url = new URL(source) 47 | url.hash = '' 48 | const docUri = url.toString() 49 | if (updatedUri === docUri) { 50 | setUpdatedTimestamp(timestamp) 51 | } 52 | } catch (e) { 53 | setError(e) 54 | } 55 | } 56 | }, [source, updatedUri, timestamp, skip]) 57 | 58 | const [loading, setLoading] = useState(false) 59 | const [error, setError] = useState() 60 | const [result, setResult] = useState() 61 | useEffect(() => { 62 | if (!skip && (source !== undefined) && (subject !== undefined) && (predicate !== undefined)) { 63 | const updateResult = async () => { 64 | setLoading(true) 65 | try { 66 | var query = data.from(source)[subject] 67 | if (predicate !== null) { 68 | query = query[predicate] 69 | } 70 | setResult(await resolver(query)) 71 | } catch (e) { 72 | setError(e) 73 | } 74 | setLoading(false) 75 | } 76 | updateResult() 77 | } 78 | }, [source, subject, predicate, updatedTimestamp, resolver, skip]) 79 | return [result, loading, error] 80 | } 81 | 82 | export function useListQuery(subject: QueryTerm, predicate: QueryTerm, options: QueryOptions = {}) { 83 | return useQuery(subject, predicate, { resolver: listResolver, ...options }) 84 | } 85 | 86 | export function useListValuesQuery(subject: QueryTerm, predicate: QueryTerm, options: QueryOptions = {}) { 87 | return useQuery(subject, predicate, { resolver: listValuesResolver, ...options }) 88 | } 89 | 90 | export function useValueQuery(subject: QueryTerm, predicate: QueryTerm, options: QueryOptions = {}) { 91 | return useQuery(subject, predicate, { resolver: valueResolver, ...options }) 92 | } 93 | 94 | export function useDateQuery(subject: QueryTerm, predicate: QueryTerm, options: QueryOptions = {}) { 95 | return useQuery(subject, predicate, { resolver: dateResolver, ...options }) 96 | } 97 | 98 | 99 | export function usePageListItems(parent: PageContainer | undefined, options: QueryOptions = {}) { 100 | return useQuery(parent && parent.pagesUri, schema.itemListElement, { resolver: pageListItemsResolver, ...options }) 101 | } 102 | 103 | export function useConceptListItems(parent: ConceptContainer | undefined, options: QueryOptions = {}) { 104 | return useQuery(parent && parent.conceptsUri, schema.itemListElement, { resolver: conceptListItemsResolver, ...options }) 105 | } 106 | 107 | export function usePageFromPageListItem(pageListItem: PageListItem, options: QueryOptions = {}) { 108 | return useQuery(pageListItem && pageListItem.pageUri, null, { resolver: pageResolver, ...options }) 109 | } 110 | 111 | export function useConcept(conceptUri: string | undefined, options: QueryOptions = {}) { 112 | return useQuery(conceptUri, null, { resolver: conceptResolver, ...options }) 113 | } 114 | 115 | export function usePage(pageUri: string | undefined, options: QueryOptions = {}) { 116 | return useQuery(pageUri, null, { resolver: pageResolver, ...options }) 117 | } 118 | 119 | export function useDocument(documentUri: string | undefined, options: QueryOptions = {}) { 120 | return useQuery(documentUri, null, { resolver: documentResolver, ...options }) 121 | } 122 | -------------------------------------------------------------------------------- /src/hooks/pages.ts: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import { usePage } from "./data" 3 | import { Page } from "../utils/model" 4 | 5 | export function useCurrentPage(): [Page | undefined, boolean, Error | undefined] { 6 | const { selectedPage } = useParams(); 7 | return usePage(selectedPage ? decodeURIComponent(selectedPage) : undefined) 8 | } 9 | 10 | export function useCurrentPageUri(): string | undefined { 11 | const { selectedPage } = useParams(); 12 | return selectedPage ? decodeURIComponent(selectedPage) : undefined 13 | } 14 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | 4 | import Backend from 'i18next-xhr-backend'; 5 | import LanguageDetector from 'i18next-browser-languagedetector'; 6 | // not like to use this? 7 | // have a look at the Quick start guide 8 | // for passing in lng and translations on init 9 | 10 | i18n 11 | // load translation using xhr -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales) 12 | // learn more: https://github.com/i18next/i18next-xhr-backend 13 | .use(Backend) 14 | // detect user language 15 | // learn more: https://github.com/i18next/i18next-browser-languageDetector 16 | .use(LanguageDetector) 17 | // pass the i18n instance to react-i18next. 18 | .use(initReactI18next) 19 | // init i18next 20 | // for all options read: https://www.i18next.com/overview/configuration-options 21 | .init({ 22 | fallbackLng: 'en', 23 | debug: true, 24 | 25 | interpolation: { 26 | escapeValue: false, // not needed for react as it escapes by default 27 | } 28 | }); 29 | 30 | 31 | export default i18n; 32 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | import './i18n' 7 | import * as Sentry from '@sentry/browser'; 8 | 9 | Sentry.init({ 10 | dsn: "https://2837c0eb80034804b6315f25c0a0e519@o382054.ingest.sentry.io/5211463", 11 | environment: process.env.NODE_ENV, 12 | release: `concept@${process.env.REACT_APP_VERSION}` 13 | }); 14 | 15 | ReactDOM.render(, document.getElementById('root')); 16 | 17 | // If you want your app to work offline and load faster, you can change 18 | // unregister() to register() below. Note this comes with some pitfalls. 19 | // Learn more about service workers: https://bit.ly/CRA-PWA 20 | serviceWorker.unregister(); 21 | -------------------------------------------------------------------------------- /src/ontology.ts: -------------------------------------------------------------------------------- 1 | const root = "https://useconcept.art/ontology#" 2 | 3 | interface ConceptOntology { 4 | backupOf: string, 5 | parent: string, 6 | inListItem: string 7 | } 8 | 9 | const ns: ConceptOntology = { 10 | backupOf: `${root}backupOf`, 11 | parent: `${root}parent`, 12 | inListItem: `${root}inListItem`, 13 | } 14 | 15 | export default ns 16 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 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 https://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.0/8 are 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) { 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 https://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, config) { 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 https://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, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready.then(registration => { 134 | registration.unregister(); 135 | }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/solid-namespace/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'solid-namespace'; 2 | -------------------------------------------------------------------------------- /src/theme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | 3 | export default createMuiTheme({ 4 | zIndex: { 5 | mobileStepper: 800, 6 | speedDial: 850, 7 | appBar: 900, 8 | drawer: 950, 9 | modal: 1000, 10 | snackbar: 1050, 11 | tooltip: 1100 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /src/utils/acl.ts: -------------------------------------------------------------------------------- 1 | import solid from 'solid-auth-client'; 2 | import { parse as parseWAC } from 'wac-allow' 3 | 4 | type Allowed = { 5 | user: Set, 6 | public: Set 7 | } 8 | 9 | export type AccessInfo = { 10 | aclUri?: string, 11 | allowed?: Allowed 12 | } 13 | 14 | export async function getAccessInfo(pageUri: string): Promise { 15 | try { 16 | const response = await solid.fetch(pageUri, { method: 'HEAD' }); 17 | const allowed = parseWAC(response.headers.get('WAC-Allow')) 18 | // for concept, acls are stored at the container level to ensure subpages inherit permissions by default 19 | const aclUri = `${pageUri.split("/").slice(0, -1).join("/")}/.acl` 20 | return { aclUri, allowed } 21 | } catch (error) { 22 | throw error; 23 | } 24 | }; 25 | 26 | export const defaultAcl = (webId: string, container: string) => `@prefix acl: . 27 | @prefix foaf: . 28 | @prefix n: . 29 | @prefix rdf: . 30 | @prefix : <${container}.acl#>. 31 | 32 | :Owners a acl:Authorization; 33 | acl:accessTo <${container}>; 34 | acl:default <${container}>; 35 | acl:agent <${webId}>; 36 | acl:mode acl:Read, acl:Write, acl:Control. 37 | :Writers a acl:Authorization; 38 | acl:accessTo <${container}>; 39 | acl:default <${container}>; 40 | acl:mode acl:Read, acl:Write. 41 | :Readers a acl:Authorization; 42 | acl:accessTo <${container}>; 43 | acl:default <${container}>; 44 | acl:mode acl:Read. 45 | :Public a acl:Authorization; 46 | acl:accessTo <${container}>; 47 | acl:default <${container}>; 48 | acl:agentClass foaf:Agent. 49 | ` 50 | 51 | export const createDefaultAcl = (webId: string, container: string) => solid.fetch(`${container}.acl`, { 52 | method: 'PUT', 53 | headers: { 54 | 'Content-Type': 'text/turtle' 55 | }, 56 | body: defaultAcl(webId, container) 57 | }) 58 | 59 | // thanks, https://github.com/inrupt/solid-react-components/blob/develop/src/lib/classes/access-control-list.js 60 | export const getParentACLUri = async (url: string): Promise => { 61 | const newURL = new URL(url); 62 | const { pathname } = newURL; 63 | const hasParent = pathname.length > 1; 64 | if (!hasParent) return undefined; 65 | const isContainer = pathname.endsWith('/'); 66 | let newPathname = isContainer ? pathname.slice(0, pathname.length - 1) : pathname; 67 | newPathname = `${newPathname.slice(0, newPathname.lastIndexOf('/'))}/`; 68 | const parentURI = `${newURL.origin}${newPathname}`; 69 | const result = await solid.fetch(`${parentURI}.acl`, { method: "HEAD" }); 70 | if (result.status === 404) return getParentACLUri(parentURI); 71 | if (result.status === 200) return `${parentURI}.acl`; 72 | 73 | return undefined; 74 | }; 75 | -------------------------------------------------------------------------------- /src/utils/backups.ts: -------------------------------------------------------------------------------- 1 | export function backupFolderForPage(pageUri: string) { 2 | return pageUri && `${pageUri.split(".").slice(0, -1).join(".")}/backups/` 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/data.ts: -------------------------------------------------------------------------------- 1 | import data from '@solid/query-ldflex'; 2 | import { namedNode } from '@rdfjs/data-model'; 3 | import { acl, schema, dc, dct, foaf } from 'rdf-namespaces'; 4 | import concept from '../ontology' 5 | import { pageUris, conceptUris, documentUris } from './model' 6 | import { patchDocument } from '../utils/ldflex-helper' 7 | import { Document, Concept, Page, PageListItem, ConceptListItem } from '../utils/model' 8 | 9 | const aclNamespace: any = acl 10 | 11 | export const follow = (webId: string, followWebId: string) => 12 | data[webId][foaf.knows].add(namedNode(followWebId)) 13 | 14 | export const unfollow = (webId: string, unfollowWebId: string) => 15 | data[webId][foaf.knows].delete(namedNode(unfollowWebId)) 16 | 17 | export const addPublicPage = (publicPageListUri: string, page: string) => 18 | data[publicPageListUri][schema.itemListElement].add(namedNode(page)) 19 | 20 | export const removePublicPage = (publicPageListUri: string, page: string) => 21 | data[publicPageListUri][schema.itemListElement].delete(namedNode(page)) 22 | 23 | export const addPublicAccess = (publicAccessUri: string, accessType: string) => 24 | data[publicAccessUri][acl.mode].add(namedNode(aclNamespace[accessType])) 25 | 26 | export const removePublicAccess = async (publicAccessUri: string, accessType: string, publicPages: string, page: string, hasWrite: boolean) => { 27 | if (accessType === "Write") { 28 | await data[publicAccessUri][acl.mode].delete(namedNode(acl.Write)) 29 | } else if (accessType === "Read") { 30 | await Promise.all([ 31 | hasWrite ? ( 32 | patchDocument(publicAccessUri, ` 33 | DELETE DATA { 34 | <${publicAccessUri}> 35 | <${acl.mode}> <${acl.Read}> ; 36 | <${acl.mode}> <${acl.Write}> . 37 | } 38 | `) 39 | ) : ( 40 | data[publicAccessUri][acl.mode].delete(namedNode(acl.Read)) 41 | ), 42 | removePublicPage(publicPages, page) 43 | ]) 44 | } 45 | } 46 | 47 | export type Resolver = (query: any) => Promise 48 | 49 | export const listResolver: Resolver = async query => { 50 | const newResult = [] 51 | for await (const result of query) { 52 | newResult.push(result) 53 | } 54 | return newResult 55 | } 56 | 57 | export const resolveValues: (terms: any[]) => any[] = terms => terms.map(term => term && term.value) 58 | 59 | export const listValuesResolver: Resolver = async query => resolveValues(await listResolver(query)) 60 | 61 | export const valueResolver: Resolver = async query => { 62 | const term = await query 63 | return term && term.value 64 | } 65 | 66 | export const dateResolver: Resolver = async query => { 67 | const term = await query 68 | return term && new Date(term.value) 69 | } 70 | 71 | export const pageListItemResolver: Resolver = async query => { 72 | const [uri, name, pageUri] = resolveValues(await Promise.all([ 73 | query, 74 | query[schema.name], 75 | query[schema.item] 76 | ])) 77 | return { uri, name, pageUri } 78 | } 79 | 80 | export const pageListItemsResolver: Resolver = async query => { 81 | const itemQueries = await listResolver(query.sort(schema.position)) 82 | return Promise.all(itemQueries.map(pageListItemResolver)) 83 | } 84 | 85 | export const conceptListItemResolver: Resolver = async query => { 86 | const [uri, name, conceptUri] = resolveValues(await Promise.all([ 87 | query, 88 | query[schema.name], 89 | query[schema.item] 90 | ])) 91 | return { uri, name, conceptUri } 92 | } 93 | 94 | export const conceptListItemsResolver: Resolver = async query => { 95 | const itemQueries = await listResolver(query.sort(schema.name)) 96 | return Promise.all(itemQueries.map(conceptListItemResolver)) 97 | } 98 | 99 | export const pageResolver: Resolver = async query => { 100 | const [uri, id, text, name, inListItem, parentUri] = resolveValues(await Promise.all([ 101 | query, query[dc.identifier], query[schema.text], query[schema.name], 102 | query[concept.inListItem], query[concept.parent] 103 | ])) 104 | return { id, text, name, uri, parentUri, inListItem, ...pageUris(uri) } 105 | } 106 | 107 | export const conceptResolver: Resolver = async query => { 108 | const [uri, id, text, name, inListItem, parentUri] = resolveValues(await Promise.all([ 109 | query, query[dc.identifier], query[schema.text], query[schema.name], 110 | query[concept.inListItem], query[concept.parent] 111 | ])) 112 | const referencedBy = await listValuesResolver(query[dct.isReferencedBy]) 113 | return { id, text, name, uri, parentUri, inListItem, referencedBy, ...conceptUris(uri) } 114 | } 115 | 116 | export const documentResolver: Resolver = async query => { 117 | const [uri, id, text, name, inListItem, parentUri] = resolveValues(await Promise.all([ 118 | query, query[dc.identifier], query[schema.text], query[schema.name], 119 | query[concept.inListItem], query[concept.parent] 120 | ])) 121 | return { id, text, name, uri, parentUri, inListItem, ...documentUris(uri) } 122 | } 123 | -------------------------------------------------------------------------------- /src/utils/editor.js: -------------------------------------------------------------------------------- 1 | import { Editor, Transforms, Range, Point, Element, Text, Path } from 'slate'; 2 | import { ReactEditor} from 'slate-react'; 3 | 4 | import imageExtensions from 'image-extensions' 5 | import isUrl from 'is-url' 6 | 7 | 8 | const LIST_TYPES = ['numbered-list', 'bulleted-list'] 9 | 10 | export const isMarkActive = (editor, format) => { 11 | const marks = Editor.marks(editor) 12 | return marks ? marks[format] === true : false 13 | } 14 | 15 | export const toggleMark = (editor, format) => { 16 | const isActive = isMarkActive(editor, format) 17 | if (isActive) { 18 | Editor.removeMark(editor, format) 19 | } else { 20 | Editor.addMark(editor, format, true) 21 | } 22 | } 23 | 24 | 25 | export const isBlockActive = (editor, format, at=editor.selection) => { 26 | const [match] = Editor.nodes(editor, { 27 | at, 28 | match: n => n.type === format, 29 | }) 30 | 31 | return !!match 32 | } 33 | 34 | export const toggleBlock = (editor, format, at=editor.selection) => { 35 | const isActive = isBlockActive(editor, format, at) 36 | const isList = LIST_TYPES.includes(format) 37 | 38 | Transforms.unwrapNodes(editor, { 39 | at, 40 | match: n => LIST_TYPES.includes(n.type), 41 | split: true, 42 | }) 43 | 44 | Transforms.setNodes(editor, { 45 | type: isActive ? 'paragraph' : isList ? 'list-item' : format, 46 | }, { at }) 47 | 48 | if (!isActive && isList) { 49 | const block = { type: format, children: [] } 50 | Transforms.wrapNodes(editor, block, { at }) 51 | } 52 | } 53 | 54 | export const makeBlock = (editor, format, at=editor.selection) => { 55 | const isList = LIST_TYPES.includes(format) 56 | 57 | Transforms.unwrapNodes(editor, { 58 | at, 59 | match: n => LIST_TYPES.includes(n.type), 60 | split: true, 61 | }) 62 | 63 | Transforms.setNodes(editor, { 64 | type: isList ? 'list-item' : format, 65 | }, { at }) 66 | 67 | if (isList) { 68 | const block = { type: format, children: [] } 69 | Transforms.wrapNodes(editor, block, { at }) 70 | } 71 | } 72 | 73 | export const insertBlock = (editor, format, at=editor.selection, attributes={}) => { 74 | const isList = LIST_TYPES.includes(format) 75 | if (format === "table") { 76 | Transforms.insertNodes(editor, { 77 | type: "table", 78 | children: [{ 79 | type: "table-row", 80 | children: [{ 81 | type: "table-cell", 82 | children: [{text: ""}] 83 | }] 84 | }], 85 | ...attributes 86 | }, { at }) 87 | } else if (isList) { 88 | Transforms.insertNodes(editor, { 89 | type: format, children: [ { type: "list-item", children: []}], 90 | ...attributes 91 | }, { at }) 92 | } else { 93 | Transforms.insertNodes(editor, 94 | { type: format, children: [], 95 | ...attributes 96 | }, 97 | { at }) 98 | } 99 | } 100 | 101 | export const insertRow = (editor, table) => { 102 | const path = ReactEditor.findPath(editor, table) 103 | Transforms.insertNodes(editor, { 104 | type: "table-row", children: Array(table.children[0].children.length).fill().map( 105 | () => ({ type: "table-cell", children: [{text: ""}]}) 106 | ) 107 | }, { at: [...path, table.children.length] }) 108 | } 109 | 110 | export const removeRow = (editor, table) => { 111 | const path = ReactEditor.findPath(editor, table) 112 | Transforms.removeNodes(editor, { at: [...path, table.children.length - 1] }) 113 | } 114 | 115 | export const insertColumn = (editor, table) => { 116 | const firstRow = table.children[0] 117 | const firstRowPath = ReactEditor.findPath(editor, firstRow) 118 | for (let i = 0; i < table.children.length; i++){ 119 | Transforms.insertNodes(editor, { 120 | type: "table-cell", children: [{text: ""}] 121 | }, { at: [...firstRowPath.slice(0, -1), i, firstRow.children.length] }) 122 | } 123 | } 124 | 125 | export const removeColumn = (editor, table) => { 126 | const firstRow = table.children[0] 127 | const firstRowPath = ReactEditor.findPath(editor, firstRow) 128 | for (let i = 0; i < table.children.length; i++){ 129 | Transforms.removeNodes(editor, { 130 | at: [...firstRowPath.slice(0, -1), i, firstRow.children.length - 1] 131 | }) 132 | } 133 | } 134 | 135 | const isImageUrl = url => { 136 | if (!url) return false 137 | if (!isUrl(url)) return false 138 | const ext = new URL(url).pathname.split('.').pop() 139 | return imageExtensions.includes(ext) 140 | } 141 | 142 | export const withImages = editor => { 143 | const { insertData, isVoid } = editor 144 | 145 | editor.isVoid = element => { 146 | return element.type === 'image' ? true : isVoid(element) 147 | } 148 | 149 | editor.insertData = data => { 150 | const text = data.getData('text/plain') 151 | const { files } = data 152 | 153 | if (files && files.length > 0) { 154 | for (const file of files) { 155 | const reader = new FileReader() 156 | const [mime] = file.type.split('/') 157 | 158 | if (mime === 'image') { 159 | reader.addEventListener('load', () => { 160 | const url = reader.result 161 | insertImage(editor, {url}) 162 | }) 163 | 164 | reader.readAsDataURL(file) 165 | } 166 | } 167 | } else if (isImageUrl(text)) { 168 | insertImage(editor, {url: text}) 169 | } else { 170 | insertData(data) 171 | } 172 | } 173 | 174 | return editor 175 | } 176 | 177 | export const insertImage = (editor, attributes, at=editor.selection) => { 178 | const text = { text: '' } 179 | const image = { type: 'image', children: [text], ...attributes } 180 | Transforms.insertNodes(editor, image, {at}) 181 | } 182 | 183 | export const isLinkActive = editor => { 184 | const [link] = Editor.nodes(editor, { match: n => n.type === 'link' }) 185 | return !!link 186 | } 187 | 188 | const unwrapLink = editor => { 189 | Transforms.unwrapNodes(editor, { match: n => n.type === 'link' }) 190 | } 191 | 192 | const wrapLink = (editor, url) => { 193 | if (isLinkActive(editor)) { 194 | unwrapLink(editor) 195 | } 196 | 197 | const { selection } = editor 198 | const isCollapsed = selection && Range.isCollapsed(selection) 199 | const link = { 200 | type: 'link', 201 | url, 202 | children: isCollapsed ? [{ text: url }] : [], 203 | } 204 | 205 | if (isCollapsed) { 206 | Transforms.insertNodes(editor, link) 207 | } else { 208 | Transforms.wrapNodes(editor, link, { split: true }) 209 | Transforms.collapse(editor, { edge: 'end' }) 210 | } 211 | } 212 | 213 | const disallowEmpty = (type, editor) => { 214 | const { normalizeNode } = editor 215 | 216 | editor.normalizeNode = entry => { 217 | const [node, path] = entry 218 | if (Element.isElement(node) && (node.type === type) && 219 | (node.children.length === 1) && Text.isText(node.children[0]) && 220 | (node.children[0].text === "")) { 221 | const currentlySelected = Path.isCommon(path, editor.selection.anchor.path) 222 | Transforms.removeNodes(editor, {at: path}) 223 | if (currentlySelected) { 224 | Transforms.select(editor, path) 225 | Transforms.collapse(editor) 226 | } 227 | } 228 | normalizeNode(entry) 229 | } 230 | } 231 | 232 | export const withLinks = editor => { 233 | const { insertData, insertText, isInline } = editor 234 | 235 | editor.isInline = element => { 236 | return element.type === 'link' ? true : isInline(element) 237 | } 238 | 239 | editor.insertText = text => { 240 | if (text && isUrl(text)) { 241 | wrapLink(editor, text) 242 | } else { 243 | insertText(text) 244 | } 245 | } 246 | 247 | editor.insertData = data => { 248 | const text = data.getData('text/plain') 249 | 250 | if (text && isUrl(text)) { 251 | wrapLink(editor, text) 252 | } else { 253 | insertData(data) 254 | } 255 | } 256 | 257 | disallowEmpty("link", editor) 258 | 259 | return editor 260 | } 261 | 262 | export const insertLink = (editor, url) => { 263 | if (editor.selection) { 264 | wrapLink(editor, url) 265 | } 266 | } 267 | 268 | export const removeLink = (editor) => { 269 | unwrapLink(editor) 270 | } 271 | 272 | export const setLinkUrl = (editor, link, url) => { 273 | const path = ReactEditor.findPath(editor, link) 274 | Transforms.setNodes(editor, {url}, {at: path}) 275 | } 276 | 277 | export const setConceptProps = (editor, concept, name, uri) => { 278 | const path = ReactEditor.findPath(editor, concept) 279 | Transforms.setNodes(editor, {name, uri}, {at: path}) 280 | } 281 | 282 | const unwrapConcept = editor => { 283 | Transforms.unwrapNodes(editor, { match: n => n.type === 'concept' }) 284 | } 285 | 286 | const wrapConcept = (editor, name, uri) => { 287 | if (isConceptActive(editor)) { 288 | unwrapConcept(editor) 289 | } 290 | 291 | const { selection } = editor 292 | const isCollapsed = selection && Range.isCollapsed(selection) 293 | const concept = { 294 | type: 'concept', 295 | name, 296 | uri, 297 | children: isCollapsed ? [{ text: name }] : [], 298 | } 299 | 300 | if (isCollapsed) { 301 | Transforms.insertNodes(editor, concept) 302 | } else { 303 | Transforms.wrapNodes(editor, concept, { split: true }) 304 | Transforms.collapse(editor, { edge: 'end' }) 305 | } 306 | } 307 | 308 | export const removeConcept = (editor) => { 309 | unwrapConcept(editor) 310 | } 311 | 312 | export const isConceptActive = editor => { 313 | const [concept] = Editor.nodes(editor, { match: n => n.type === 'concept' }) 314 | return !!concept 315 | } 316 | 317 | export const insertConcept = (editor, name, uri) => { 318 | if (editor.selection) { 319 | wrapConcept(editor, name, uri) 320 | } 321 | } 322 | 323 | export const withConcepts = editor => { 324 | const { isInline } = editor 325 | 326 | editor.isInline = element => (element.type === 'concept') ? true : isInline(element) 327 | disallowEmpty("concept", editor) 328 | 329 | return editor 330 | } 331 | 332 | export const withChecklists = editor => { 333 | const { deleteBackward } = editor 334 | 335 | editor.deleteBackward = (...args) => { 336 | const { selection } = editor 337 | 338 | if (selection && Range.isCollapsed(selection)) { 339 | const [match] = Editor.nodes(editor, { 340 | match: n => n.type === 'check-list-item', 341 | }) 342 | 343 | if (match) { 344 | const [, path] = match 345 | const start = Editor.start(editor, path) 346 | 347 | if (Point.equals(selection.anchor, start)) { 348 | Transforms.setNodes( 349 | editor, 350 | { type: 'paragraph' }, 351 | { match: n => n.type === 'check-list-item' } 352 | ) 353 | return 354 | } 355 | } 356 | } 357 | 358 | deleteBackward(...args) 359 | } 360 | 361 | return editor 362 | } 363 | 364 | export const withLists = editor => { 365 | const { deleteBackward } = editor 366 | 367 | editor.deleteBackward = (...args) => { 368 | const { selection } = editor 369 | 370 | if (selection && Range.isCollapsed(selection)) { 371 | const [match] = Editor.nodes(editor, { 372 | match: n => n.type === 'list-item', 373 | }) 374 | if (match) { 375 | const [, path] = match 376 | const start = Editor.start(editor, path) 377 | if (Point.equals(selection.anchor, start)) { 378 | Transforms.unwrapNodes(editor, { 379 | match: n => LIST_TYPES.includes(n.type), 380 | split: true, 381 | }) 382 | 383 | Transforms.setNodes( 384 | editor, 385 | { type: 'paragraph' }, 386 | { match: n => n.type === 'list-item' } 387 | ) 388 | return 389 | } 390 | } 391 | } 392 | 393 | deleteBackward(...args) 394 | } 395 | 396 | return editor 397 | } 398 | 399 | export const withTables = editor => { 400 | const { deleteBackward, deleteForward, insertBreak } = editor 401 | 402 | editor.deleteBackward = unit => { 403 | const { selection } = editor 404 | 405 | if (selection && Range.isCollapsed(selection)) { 406 | const [cell] = Editor.nodes(editor, { 407 | match: n => n.type === 'table-cell', 408 | }) 409 | 410 | if (cell) { 411 | const [, cellPath] = cell 412 | const start = Editor.start(editor, cellPath) 413 | 414 | if (Point.equals(selection.anchor, start)) { 415 | return 416 | } 417 | } 418 | } 419 | 420 | deleteBackward(unit) 421 | } 422 | 423 | editor.deleteForward = unit => { 424 | const { selection } = editor 425 | 426 | if (selection && Range.isCollapsed(selection)) { 427 | const [cell] = Editor.nodes(editor, { 428 | match: n => n.type === 'table-cell', 429 | }) 430 | 431 | if (cell) { 432 | const [, cellPath] = cell 433 | const end = Editor.end(editor, cellPath) 434 | 435 | if (Point.equals(selection.anchor, end)) { 436 | return 437 | } 438 | } 439 | } 440 | 441 | deleteForward(unit) 442 | } 443 | 444 | editor.insertBreak = () => { 445 | const { selection } = editor 446 | 447 | if (selection) { 448 | const [table] = Editor.nodes(editor, { match: n => n.type === 'table' }) 449 | 450 | if (table) { 451 | return 452 | } 453 | } 454 | 455 | insertBreak() 456 | } 457 | 458 | return editor 459 | } 460 | 461 | export const withEmbeds = editor => { 462 | const { isVoid } = editor 463 | editor.isVoid = element => (element.type === 'embed' ? true : isVoid(element)) 464 | return editor 465 | } 466 | 467 | export const insertionPoint = (editor, element) => { 468 | const path = ReactEditor.findPath(editor, element) 469 | return ( 470 | [...path.slice(0, -1), path.slice(-1)[0] + 1] 471 | ) 472 | } 473 | -------------------------------------------------------------------------------- /src/utils/fonts.js: -------------------------------------------------------------------------------- 1 | export const titleFont = "'Special Elite', cursive" 2 | -------------------------------------------------------------------------------- /src/utils/ldflex-helper.js: -------------------------------------------------------------------------------- 1 | import auth from 'solid-auth-client'; 2 | import ldflex from '@solid/query-ldflex'; 3 | 4 | /* 5 | Thanks, https://github.com/inrupt/generator-solid-react/blob/develop/generators/app/templates/src/utils/ldflex-helper.js 6 | */ 7 | 8 | export const documentExists = async documentUri => 9 | auth.fetch(documentUri, { 10 | method: 'HEAD', 11 | headers: { 12 | 'Content-Type': 'text/turtle' 13 | } 14 | }); 15 | 16 | export const createDoc = async (documentUri, options) => { 17 | try { 18 | return await auth.fetch(documentUri, options); 19 | } catch (e) { 20 | throw e; 21 | } 22 | }; 23 | 24 | export const deleteFile = async url => { 25 | try { 26 | return await auth.fetch(url, { method: 'DELETE' }); 27 | } catch (e) { 28 | throw e; 29 | } 30 | }; 31 | 32 | export const createDocument = async (documentUri, body = '') => { 33 | try { 34 | const options = { 35 | method: 'PUT', 36 | headers: { 37 | 'Content-Type': 'text/turtle' 38 | }, 39 | body 40 | }; 41 | return await createDoc(documentUri, options); 42 | } catch (e) { 43 | throw e; 44 | } 45 | }; 46 | 47 | export const patchDocument = async (documentUri, body = '') => { 48 | try { 49 | const options = { 50 | method: 'PATCH', 51 | headers: { 52 | 'Content-Type': 'application/sparql-update' 53 | }, 54 | body 55 | }; 56 | return await auth.fetch(documentUri, options); 57 | } catch (e) { 58 | throw e; 59 | } 60 | }; 61 | 62 | export const createDocumentWithTurtle = async (documentUri, body = '') => { 63 | try { 64 | return createDoc(documentUri, { 65 | method: 'PUT', 66 | headers: { 67 | 'Content-Type': 'text/turtle' 68 | }, 69 | body 70 | }); 71 | } catch (e) { 72 | throw e; 73 | } 74 | }; 75 | 76 | export const createNonExistentDocument = async (documentUri, body = '') => { 77 | try { 78 | const result = await documentExists(documentUri); 79 | 80 | return result.status === 404 ? createDocument(documentUri, body) : null; 81 | } catch (e) { 82 | throw e; 83 | } 84 | }; 85 | 86 | export const fetchLdflexDocument = async documentUri => { 87 | try { 88 | const result = await documentExists(documentUri); 89 | if (result.status === 404) return null; 90 | const document = await ldflex[documentUri]; 91 | return document; 92 | } catch (e) { 93 | throw e; 94 | } 95 | }; 96 | 97 | export const resourceExists = async resourcePath => { 98 | try { 99 | const result = await auth.fetch(resourcePath, {method: 'HEAD'}); 100 | return result.status === 403 || result.status === 200; 101 | } catch (e) { 102 | console.log("error: ", e) 103 | } 104 | }; 105 | 106 | export const discoverInbox = async document => { 107 | try { 108 | const documentExists = await resourceExists(document); 109 | if (!documentExists) return false; 110 | 111 | const inboxDocument = await ldflex[document]['ldp:inbox']; 112 | const inbox = inboxDocument ? await inboxDocument.value : false; 113 | return inbox; 114 | } catch (error) { 115 | throw error; 116 | } 117 | }; 118 | 119 | /** 120 | * Given a resource link, find an inbox linked from it, if any exist 121 | * @param resourcePath 122 | * @returns {Promise} 123 | */ 124 | export const getLinkedInbox = async resourcePath => { 125 | try { 126 | const inboxLinkedPath = await ldflex[resourcePath].inbox; 127 | if (inboxLinkedPath) { 128 | return inboxLinkedPath.value; 129 | } 130 | return ''; 131 | } catch (error) { 132 | throw error; 133 | } 134 | }; 135 | -------------------------------------------------------------------------------- /src/utils/model.ts: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid/v1'; 2 | import { schema, rdf, dc, dct } from 'rdf-namespaces'; 3 | import data from '@solid/query-ldflex'; 4 | import { resourceExists, createDocument, patchDocument } from './ldflex-helper'; 5 | import cpt from '../ontology'; 6 | import { pageResolver } from './data'; 7 | import { conceptNameToUrlSafeId, urlSafeIdToConceptName, conceptContainerUrl, publicPagesUrl } from '../utils/urls'; 8 | 9 | export interface Subject { 10 | uri: string, 11 | docUri: string, 12 | containerUri: string 13 | } 14 | 15 | export interface PageContainer extends Subject { 16 | subpageContainerUri: string, 17 | pagesUri: string 18 | } 19 | 20 | export interface ConceptContainer extends Subject { 21 | conceptContainerUri: string 22 | conceptsUri: string, 23 | } 24 | 25 | export interface Document extends Subject { 26 | id: string, 27 | name: string, 28 | text: string, 29 | imageContainerUri: string, 30 | metaUri: string, 31 | inListItem: string, 32 | parentUri: string 33 | } 34 | 35 | export interface Workspace extends PageContainer, ConceptContainer { 36 | publicPages: string, 37 | } 38 | 39 | export interface Concept extends Document { 40 | referencedBy: string[] 41 | } 42 | 43 | export interface Page extends PageContainer, Document { 44 | } 45 | 46 | export interface PageListItem { 47 | uri: string, 48 | name: string, 49 | pageUri: string 50 | } 51 | 52 | export interface ConceptListItem { 53 | uri: string, 54 | name: string, 55 | conceptUri: string 56 | } 57 | 58 | export interface PageProps { 59 | name?: string 60 | } 61 | 62 | export interface ConceptProps { 63 | name: string 64 | } 65 | 66 | export interface PageListItemProps { 67 | position?: number 68 | } 69 | 70 | export function isPage(document: Document): document is Page { 71 | return (document as Page).pagesUri !== undefined 72 | } 73 | 74 | export function isConcept(document: Document): document is Concept { 75 | return (document as Concept).referencedBy !== undefined 76 | } 77 | 78 | const initialDocumentText = JSON.stringify([ 79 | { 80 | type: 'paragraph', 81 | children: [{ text: '' }] 82 | } 83 | ]) 84 | 85 | export function conceptUris(uri: string) { 86 | const { containerUri, docUri, metaUri, imageContainerUri } = documentUris(uri) 87 | return ({ containerUri, docUri, uri, imageContainerUri, metaUri }) 88 | } 89 | 90 | type ConceptOptions = { 91 | referencedBy?: string 92 | } 93 | 94 | export function newConcept(workspace: Workspace, name: string, options: ConceptOptions = {}): Concept { 95 | // ok this looks insane but we want to support arbitrary characters in names and there are 96 | // some issues with % chars in path segments in browsers: https://github.com/ReactTraining/history/issues/505 97 | const id = conceptNameToUrlSafeId(name) 98 | const inListItem = `${workspace.docUri}#${id}` 99 | const referencedBy = options.referencedBy ? [options.referencedBy] : [] 100 | return ({ 101 | id, 102 | name, 103 | parentUri: workspace.conceptsUri, 104 | text: initialDocumentText, 105 | inListItem, 106 | referencedBy, 107 | ...conceptUris(`${workspace.conceptContainerUri}${id}/index.ttl#Concept`) 108 | }) 109 | } 110 | 111 | 112 | const addConceptMetadata = async (parent: ConceptContainer, concept: Concept) => { 113 | await Promise.all([ 114 | patchDocument(parent.docUri, ` 115 | INSERT DATA { 116 | <${concept.inListItem}> 117 | <${rdf.type}> <${schema.ListItem}> ; 118 | <${schema.name}> """${concept.name}""" ; 119 | <${schema.item}> <${concept.uri}> . 120 | 121 | <${parent.conceptsUri}> <${schema.itemListElement}> <${concept.inListItem}> . 122 | } 123 | `), 124 | createDocument(concept.metaUri, ` 125 | <${concept.uri}> <${schema.name}> """${concept.name}""" . 126 | `) 127 | ]) 128 | return concept 129 | } 130 | 131 | 132 | const optionalConceptDoubles = ({ referencedBy }: ConceptOptions) => { 133 | if (referencedBy) { 134 | return `<${dct.isReferencedBy}> <${referencedBy}> ;` 135 | } else { 136 | return "" 137 | } 138 | } 139 | 140 | export const addConcept = async (workspace: Workspace, name: string, options: ConceptOptions = {}) => { 141 | const concept = newConcept(workspace, name, options) 142 | await Promise.all([ 143 | createDocument(concept.docUri, ` 144 | <${concept.uri}> 145 | <${rdf.type}> <${schema.DigitalDocument}> ; 146 | <${dc.identifier}> "${concept.id}" ; 147 | <${schema.text}> """${concept.text}""" ; 148 | <${schema.name}> """${concept.name}""" ; 149 | ${optionalConceptDoubles(options)} 150 | <${cpt.parent}> <${workspace.conceptsUri}> . 151 | `), 152 | addConceptMetadata(workspace, concept) 153 | ]) 154 | return concept 155 | } 156 | 157 | export function metaForPageUri(pageUri: string) { 158 | return `${pageUri.split("/").slice(0, -1).join("/")}/.meta` 159 | } 160 | 161 | export function documentUris(uri: string) { 162 | const containerUri = `${uri.split("/").slice(0, -1).join("/")}/` 163 | const docUri = `${containerUri}index.ttl` 164 | const metaUri = `${containerUri}.meta` 165 | const imageContainerUri = `${containerUri}images/` 166 | return { containerUri, docUri, metaUri, imageContainerUri } 167 | } 168 | 169 | export function pageUris(uri: string) { 170 | const { containerUri, docUri, metaUri, imageContainerUri } = documentUris(uri) 171 | const subpageContainerUri = `${containerUri}pages/` 172 | const pagesUri = `${docUri}#Pages` 173 | return ({ containerUri, docUri, uri, subpageContainerUri, imageContainerUri, metaUri, pagesUri }) 174 | } 175 | 176 | export function newPage(parent: PageContainer, { name = "Untitled" } = {}): Page { 177 | const id = uuid() 178 | const inListItem = `${parent.docUri}#${id}` 179 | return ({ 180 | id, 181 | name, 182 | text: initialDocumentText, 183 | inListItem, 184 | parentUri: parent.pagesUri, 185 | ...pageUris(`${parent.subpageContainerUri}${id}/index.ttl#Page`) 186 | }) 187 | } 188 | 189 | const addPageMetadata = async (parent: PageContainer, page: Page, props: PageListItemProps = {}) => { 190 | await Promise.all([ 191 | patchDocument(parent.docUri, ` 192 | INSERT DATA { 193 | <${page.inListItem}> 194 | <${rdf.type}> <${schema.ListItem}> ; 195 | <${schema.item}> <${page.uri}> ; 196 | <${schema.name}> """${page.name}""" ; 197 | <${schema.position}> "${props.position || 0}"^^ . 198 | <${parent.pagesUri}> <${schema.itemListElement}> <${page.inListItem}> . 199 | } 200 | `), 201 | createDocument(page.metaUri, ` 202 | <${page.uri}> <${schema.name}> """${page.name}""" . 203 | `) 204 | ]) 205 | return page 206 | } 207 | 208 | export const addPage = async (parent: PageContainer, pageProps = {}, pageListItemProps = {}) => { 209 | const page = newPage(parent, pageProps) 210 | await createDocument(page.docUri, ` 211 | <${page.uri}> 212 | <${rdf.type}> <${schema.DigitalDocument}> ; 213 | <${dc.identifier}> "${page.id}" ; 214 | <${schema.text}> """${page.text}""" ; 215 | <${schema.name}> """${page.name}""" ; 216 | <${cpt.parent}> <${parent.pagesUri}> ; 217 | <${cpt.inListItem}> <${page.inListItem}> . 218 | `) 219 | await addPageMetadata(parent, page, pageListItemProps) 220 | return page 221 | } 222 | 223 | const conceptDocFromConceptUri = (conceptUri: string) => 224 | conceptUri.split("#").slice(0, -1).join("") 225 | 226 | const conceptNameFromConceptUri = (conceptUri: string) => 227 | urlSafeIdToConceptName(conceptUri.split("/").slice(-2)[0]) 228 | 229 | const addConceptReferencedBy = async (workspace: Workspace, docUri: string, conceptUri: string) => { 230 | const resourceUri = conceptDocFromConceptUri(conceptUri) 231 | if (await resourceExists(resourceUri)) { 232 | await patchDocument(resourceUri, ` 233 | INSERT DATA { 234 | <${conceptUri}> <${dct.isReferencedBy}> <${docUri}> 235 | } 236 | `) 237 | } else { 238 | await addConcept(workspace, conceptNameFromConceptUri(conceptUri), { referencedBy: docUri }) 239 | } 240 | } 241 | 242 | const addConceptReferencedBys = async (workspace: Workspace, docUri: string, conceptUris: string[]) => { 243 | await Promise.all(conceptUris.map( 244 | conceptUri => addConceptReferencedBy(workspace, docUri, conceptUri) 245 | )) 246 | } 247 | 248 | const deleteConceptReferencedBy = async (docUri: string, conceptUri: string) => { 249 | await patchDocument(conceptDocFromConceptUri(conceptUri), ` 250 | DELETE DATA { 251 | <${conceptUri}> <${dct.isReferencedBy}> <${docUri}> 252 | } 253 | `) 254 | } 255 | 256 | const deleteConceptReferencedBys = async (docUri: string, conceptUris: string[]) => { 257 | await Promise.all(conceptUris.map( 258 | conceptUri => deleteConceptReferencedBy(docUri, conceptUri) 259 | )) 260 | } 261 | 262 | const referenceDoubles = (references: string[]) => 263 | references.map(reference => `<${dct.references}> <${reference}> ;`).join("") 264 | 265 | export const setDocumentText = async (workspace: Workspace, doc: Document, newText: string, referencesToAdd: string[], referencesToDelete: string[]) => { 266 | await Promise.all([ 267 | patchDocument(doc.docUri, ` 268 | DELETE DATA { 269 | <${doc.uri}> 270 | ${referenceDoubles(referencesToDelete)} 271 | <${schema.text}> """${doc.text}""" . 272 | } ; 273 | INSERT DATA { 274 | <${doc.uri}> 275 | ${referenceDoubles(referencesToAdd)} 276 | <${schema.text}> """${newText}""" . 277 | } 278 | `), 279 | deleteConceptReferencedBys(doc.uri, referencesToDelete), 280 | addConceptReferencedBys(workspace, doc.uri, referencesToAdd) 281 | ]) 282 | } 283 | 284 | 285 | export const addSubPage = async (pageListItem: PageListItem, pageProps = {}, pageListItemProps = {}) => { 286 | const parentPage = await pageResolver(data[pageListItem.pageUri]) 287 | return await addPage(parentPage, pageProps, pageListItemProps) 288 | } 289 | 290 | export function workspaceFromStorage(storage: string): Workspace { 291 | const conceptContainer = conceptContainerUrl(storage) 292 | const publicPages = publicPagesUrl(conceptContainer) 293 | const workspaceContainer = `${conceptContainer}workspace/` 294 | const workspaceDoc = `${workspaceContainer}index.ttl` 295 | const uri = `${workspaceDoc}#Workspace` 296 | const pagesUri = `${workspaceDoc}#Pages` 297 | const conceptsUri = `${workspaceDoc}#Concepts` 298 | return ({ 299 | containerUri: workspaceContainer, 300 | uri, 301 | pagesUri, 302 | conceptsUri, 303 | docUri: workspaceDoc, 304 | subpageContainerUri: `${workspaceContainer}pages/`, 305 | conceptContainerUri: `${workspaceContainer}concepts/`, 306 | publicPages 307 | }) 308 | 309 | } 310 | -------------------------------------------------------------------------------- /src/utils/slate.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "slate" 2 | 3 | export const getConceptNodes = (node: Node) => Array.from(Node.nodes(node)).filter(([n]) => { 4 | return (n.type === 'concept') 5 | }) 6 | 7 | export const getConceptNodesMatchingName = (node: Node, name: string) => Array.from(Node.nodes(node)).filter(([n]) => { 8 | return (n.type === 'concept') && (n.name === name) 9 | }) 10 | -------------------------------------------------------------------------------- /src/utils/urls.ts: -------------------------------------------------------------------------------- 1 | import base32 from 'base32' 2 | 3 | export const conceptContainerUrl = (storage: string) => `${storage}private/concept/v5.12/` 4 | export const appContainerUrl = conceptContainerUrl 5 | export const publicPagesUrl = (conceptContainer: string) => `${conceptContainer}publicPages.ttl` 6 | export const pagePath = (page: string) => `/page/${encodeURIComponent(page)}` 7 | export const sharingUrl = (page: string) => `https://useconcept.art${pagePath(page)}` 8 | export const webIdProfilePath = (webId: string) => `/webid/${encodeURIComponent(webId)}` 9 | export const handleProfilePath = (handle: string) => `/for/${handle}` 10 | export const handleHausUriForWebId = (webId: string) => `https://handle.haus/webids/${encodeURIComponent(webId)}#Person` 11 | 12 | export const conceptNameToUrlSafeId = (name: string) => 13 | base32.encode(encodeURIComponent(name)) 14 | export const urlSafeIdToConceptName = (id: string) => 15 | decodeURIComponent(base32.decode(id)) 16 | 17 | export const conceptPath = (conceptUri: string) => `/concept/${encodeURIComponent(conceptUri)}` 18 | export const conceptUrl = (conceptUri: string) => { 19 | const u = new URL(window.location.toString()) 20 | u.pathname = conceptPath(conceptUri) 21 | return u.toString() 22 | } 23 | export const conceptUri = (container: string, name: string) => `${container}${conceptNameToUrlSafeId(name)}/index.ttl#Concept` 24 | export const documentPath = (documentUri: string) => { 25 | if (documentUri.endsWith("Concept")) { 26 | return conceptPath(documentUri) 27 | } else if (documentUri.endsWith("Page")) { 28 | return pagePath(documentUri) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/wac-allow/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'wac-allow'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 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": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------