├── pnpm-workspace.yaml ├── data-browser ├── public │ ├── robots.txt │ ├── _config.yml │ ├── app_data │ │ └── images │ │ │ ├── icon.png │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── maskable_icon.png │ │ │ ├── mstile-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ ├── mstile-310x150.png │ │ │ ├── mstile-310x310.png │ │ │ ├── mstile-70x70.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── maskable_icon_x128.png │ │ │ ├── maskable_icon_x192.png │ │ │ ├── maskable_icon_x384.png │ │ │ ├── maskable_icon_x512.png │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── default_social_preview.jpg │ │ │ └── mask-icon.svg │ └── browserconfig.xml ├── src │ ├── routes │ │ ├── History │ │ │ ├── index.ts │ │ │ ├── HistoryViewProps.ts │ │ │ ├── VersionTitle.tsx │ │ │ ├── useVersions.ts │ │ │ ├── VersionButton.tsx │ │ │ ├── versionHelpers.ts │ │ │ └── HistoryDesktopView.tsx │ │ ├── Sandbox.tsx │ │ ├── LocalRoute.tsx │ │ ├── paths.tsx │ │ ├── ShowRoute.tsx │ │ ├── TokenRoute.tsx │ │ ├── SettingsServer │ │ │ ├── FavoriteButton.tsx │ │ │ └── DrivesCard.tsx │ │ ├── ShortcutsRoute.tsx │ │ └── EditRoute.tsx │ ├── helpers │ │ ├── ViewTransitionProps.ts │ │ ├── effectTimeout.ts │ │ ├── tauri.tsx │ │ ├── useFocus.tsx │ │ ├── transition.ts │ │ ├── useMedia.tsx │ │ ├── useNewRoute.ts │ │ ├── timeoutEffect.ts │ │ ├── agentStorage.tsx │ │ ├── debounce.ts │ │ ├── formatTimeAgo.ts │ │ ├── markdown.ts │ │ ├── loggingHandlers.tsx │ │ ├── transitionName.ts │ │ └── useHover.tsx │ ├── views │ │ ├── Article │ │ │ ├── index.ts │ │ │ └── ArticleCard.tsx │ │ ├── FolderPage │ │ │ ├── GridItem │ │ │ │ ├── GridItemViewProps.tsx │ │ │ │ ├── DefaultGridItem.tsx │ │ │ │ ├── BasicGridItem.tsx │ │ │ │ ├── DocumentGridItem.tsx │ │ │ │ └── BookmarkGridItem.tsx │ │ │ ├── FolderDisplayStyle.ts │ │ │ ├── iconMap.ts │ │ │ ├── DisplayStyleButton.tsx │ │ │ └── GridView.tsx │ │ ├── File │ │ │ ├── isTextFile.ts │ │ │ ├── displayFileSize.ts │ │ │ ├── useFileImageTransitionStyles.ts │ │ │ ├── FileCard.tsx │ │ │ ├── TextPreview.tsx │ │ │ ├── DownloadButton.tsx │ │ │ └── FilePage.tsx │ │ ├── Card │ │ │ ├── CardViewProps.tsx │ │ │ ├── ElementCard.tsx │ │ │ ├── MessageCard.tsx │ │ │ ├── BookmarkCard.tsx │ │ │ └── CollectionCard.tsx │ │ ├── AgentPage.tsx │ │ ├── MessagePage.tsx │ │ ├── CrashPage.tsx │ │ ├── ResourceLine.tsx │ │ ├── BookmarkPage │ │ │ └── BookmarkPreview.tsx │ │ ├── ResourceInline.tsx │ │ ├── README.md │ │ └── ClassPage.tsx │ ├── components │ │ ├── Gutter.tsx │ │ ├── Slot.tsx │ │ ├── Dialog │ │ │ ├── dialogContext.ts │ │ │ ├── DialogContainer.tsx │ │ │ └── useDialog.tsx │ │ ├── Dropdown │ │ │ ├── DropdownTrigger.ts │ │ │ └── DefaultTrigger.tsx │ │ ├── datatypes │ │ │ ├── DateTime.tsx │ │ │ ├── NestedResource.tsx │ │ │ └── ResourceArray.tsx │ │ ├── SideBar │ │ │ ├── SideBarHeader.ts │ │ │ ├── menuItems.tsx │ │ │ ├── SideBarItem.ts │ │ │ ├── SideBarMenuItem.tsx │ │ │ ├── About.tsx │ │ │ └── ResourceSideBar │ │ │ │ └── FloatingActions.tsx │ │ ├── Detail.tsx │ │ ├── NewInstanceButton │ │ │ ├── NewInstanceButtonProps.ts │ │ │ ├── NewInstanceButtonDefault.tsx │ │ │ ├── index.tsx │ │ │ └── Base.tsx │ │ ├── SignInButton.tsx │ │ ├── Containers.tsx │ │ ├── ResourceContextMenu │ │ │ └── MenuBarDropdownTrigger.tsx │ │ ├── forms │ │ │ ├── NewForm │ │ │ │ ├── SubjectField.tsx │ │ │ │ ├── NewFormPage.tsx │ │ │ │ └── NewFormTitle.tsx │ │ │ ├── InputBoolean.tsx │ │ │ ├── InputString.tsx │ │ │ ├── InputResource.tsx │ │ │ ├── InputNumber.tsx │ │ │ ├── InputSwitcher.tsx │ │ │ ├── hooks │ │ │ │ └── useSaveResource.ts │ │ │ ├── InputMarkdown.tsx │ │ │ ├── Checkbox.tsx │ │ │ └── UploadForm.tsx │ │ ├── ClassDetail.tsx │ │ ├── Title.tsx │ │ ├── Loader.tsx │ │ ├── FilePill.tsx │ │ ├── ChildrenList.tsx │ │ ├── Shortcut.tsx │ │ ├── Spinner.tsx │ │ ├── NewCard.tsx │ │ ├── NavBarSpacer.tsx │ │ ├── IconButton │ │ │ └── IconButton.story.mdx │ │ ├── CommitDetail.tsx │ │ ├── Collapse.tsx │ │ ├── ErrorLook.tsx │ │ ├── AllProps.tsx │ │ ├── ExternalLink.tsx │ │ ├── ValueComp.tsx │ │ ├── MetaSetter.tsx │ │ ├── CodeBlock.tsx │ │ ├── NetworkIndicator.tsx │ │ ├── Card.tsx │ │ ├── Row.tsx │ │ ├── NavStyleButton.tsx │ │ └── SearchFilter.tsx │ ├── ontologies │ │ └── atomic-argu.ts │ ├── config.ts │ ├── handlers │ │ ├── errorHandler.ts │ │ ├── index.ts │ │ └── sideBarHandler.ts │ ├── hooks │ │ ├── useEffectOnce.ts │ │ ├── useCombineRefs.ts │ │ ├── useFilePreviewSizeLimit.ts │ │ ├── useOnline.ts │ │ ├── useMediaQuery.ts │ │ ├── useFile.ts │ │ ├── useGlobalStylesWhileMounted.ts │ │ ├── useQueryScope.ts │ │ ├── useNavigateWithTransition.ts │ │ ├── useDriveHistory.ts │ │ ├── useUpload.ts │ │ ├── useSavedDrives.ts │ │ └── useClickAwayListener.ts │ ├── index.tsx │ └── chunks │ │ └── PDFViewer │ │ └── index.tsx ├── tests │ ├── e2e.spec.ts-snapshots │ │ ├── data-browser-upload-download-1-linux.png │ │ └── data-browser-upload-download-1-darwin.png │ ├── test-config.ts │ └── playwright.config.ts ├── jest.config.cjs └── tsconfig.json ├── lib ├── src │ ├── hasBrowserAPI.ts │ ├── search.test.ts │ ├── agent.test.ts │ ├── truncate.ts │ ├── endpoints.ts │ ├── EventManager.test.ts │ ├── index.ts │ ├── EventManager.ts │ └── store.test.ts ├── tsconfig.json ├── README.md ├── jest.config.cjs └── package.json ├── .prettierignore ├── README.md ├── DOCS.MD ├── react ├── src │ ├── helpers │ │ └── isDev.ts │ ├── useCurrentAgent.ts │ ├── useChildren.ts │ ├── useDebounce.ts │ ├── useServerURL.ts │ ├── index.ts │ ├── useLocalStorage.ts │ └── useServerSearch.tsx ├── tsconfig.json ├── package.json └── README.md ├── typedoc.json ├── .vscode ├── extensions.json ├── tasks.json └── settings.json ├── .prettierrc.json ├── pull_request_template.md ├── .gitignore ├── tsconfig.json ├── LICENSE ├── tsconfig.build.json ├── .github └── workflows │ └── deploy.yml ├── lerna-debug.log ├── debug.log └── package.json /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data-browser/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /data-browser/src/routes/History/index.ts: -------------------------------------------------------------------------------- 1 | export { History } from './HistoryRoute'; 2 | -------------------------------------------------------------------------------- /data-browser/src/helpers/ViewTransitionProps.ts: -------------------------------------------------------------------------------- 1 | export interface ViewTransitionProps { 2 | subject?: string; 3 | } 4 | -------------------------------------------------------------------------------- /lib/src/hasBrowserAPI.ts: -------------------------------------------------------------------------------- 1 | export function hasBrowserAPI(): boolean { 2 | return globalThis === globalThis.window; 3 | } 4 | -------------------------------------------------------------------------------- /data-browser/src/views/Article/index.ts: -------------------------------------------------------------------------------- 1 | export { ArticlePage } from './ArticlePage'; 2 | export { ArticleCard } from './ArticleCard'; 3 | -------------------------------------------------------------------------------- /data-browser/public/_config.yml: -------------------------------------------------------------------------------- 1 | # This is a fix for making sure github pages serves the doc pages 2 | include: 3 | - "_*_.html" 4 | - "_*_.*.html" 5 | -------------------------------------------------------------------------------- /data-browser/public/app_data/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/icon.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | **/node_modules 3 | **/dist 4 | **/package.json 5 | **/yarn.lock 6 | **/package-lock.json 7 | **/.eslintrc.js 8 | **/tsconfig.json 9 | -------------------------------------------------------------------------------- /data-browser/public/app_data/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/favicon.ico -------------------------------------------------------------------------------- /data-browser/src/components/Gutter.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Gutter = styled.div` 4 | height: ${p => p.theme.margin}rem; 5 | `; 6 | -------------------------------------------------------------------------------- /data-browser/src/components/Slot.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Slot = styled.div` 4 | grid-area: ${props => props.slot}; 5 | `; 6 | -------------------------------------------------------------------------------- /data-browser/public/app_data/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/favicon-16x16.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/favicon-32x32.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/maskable_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/maskable_icon.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/mstile-144x144.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/mstile-150x150.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/mstile-310x150.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/mstile-310x310.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/mstile-70x70.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/apple-touch-icon.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Atomic Data Browser](./logo.svg) 2 | 3 | THIS REPO IS NO LONGER USED, ATOMIC-DATA-BROWSER IS NOW PART OF [ATOMIC-SERVER](https://github.com/atomicdata-dev/atomic-server/). 4 | -------------------------------------------------------------------------------- /data-browser/public/app_data/images/maskable_icon_x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/maskable_icon_x128.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/maskable_icon_x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/maskable_icon_x192.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/maskable_icon_x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/maskable_icon_x384.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/maskable_icon_x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/maskable_icon_x512.png -------------------------------------------------------------------------------- /data-browser/src/ontologies/atomic-argu.ts: -------------------------------------------------------------------------------- 1 | export const atomicArgu = { 2 | properties: { 3 | coverImage: 'https://atomicdata.dev/Folder/wp8ame4nqf/urHO7G8FKm', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/GridItem/GridItemViewProps.tsx: -------------------------------------------------------------------------------- 1 | import { Resource } from '@tomic/react'; 2 | 3 | export interface GridItemViewProps { 4 | resource: Resource; 5 | } 6 | -------------------------------------------------------------------------------- /data-browser/public/app_data/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/android-chrome-512x512.png -------------------------------------------------------------------------------- /data-browser/public/app_data/images/default_social_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/public/app_data/images/default_social_preview.jpg -------------------------------------------------------------------------------- /data-browser/src/helpers/effectTimeout.ts: -------------------------------------------------------------------------------- 1 | export const effectTimeout = (effect: () => void, ms: number) => { 2 | const id = setTimeout(effect, ms); 3 | 4 | return () => clearTimeout(id); 5 | }; 6 | -------------------------------------------------------------------------------- /data-browser/src/views/File/isTextFile.ts: -------------------------------------------------------------------------------- 1 | export const isTextFile = (mimeType: string): boolean => 2 | mimeType !== 'application/pdf' && 3 | (mimeType?.startsWith('text/') || mimeType?.startsWith('application/')); 4 | -------------------------------------------------------------------------------- /data-browser/tests/e2e.spec.ts-snapshots/data-browser-upload-download-1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/tests/e2e.spec.ts-snapshots/data-browser-upload-download-1-linux.png -------------------------------------------------------------------------------- /DOCS.MD: -------------------------------------------------------------------------------- 1 | ## Atomic Data Typescript (@tomic) Docs 2 | 3 | Documentation for `@tomic/lib` and `@tomic/react`. 4 | 5 | See [the Github repository](https://github.com/atomicdata-dev/atomic-data-browser) for more information and issues. 6 | -------------------------------------------------------------------------------- /data-browser/src/config.ts: -------------------------------------------------------------------------------- 1 | /** Returns true if this is run in locally, in Development mode */ 2 | export function isDev(): boolean { 3 | //@ts-ignore This key does exist 4 | return import.meta.env['MODE'] === 'development'; 5 | } 6 | -------------------------------------------------------------------------------- /data-browser/tests/e2e.spec.ts-snapshots/data-browser-upload-download-1-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicdata-dev/atomic-data-browser/HEAD/data-browser/tests/e2e.spec.ts-snapshots/data-browser-upload-download-1-darwin.png -------------------------------------------------------------------------------- /react/src/helpers/isDev.ts: -------------------------------------------------------------------------------- 1 | /** Returns true if this is run in locally, in Development mode */ 2 | export function isDev(): boolean { 3 | //@ts-ignore This key does exist 4 | return import.meta.env['MODE'] === 'development'; 5 | } 6 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPointStrategy": "packages", 4 | "excludeExternals": true, 5 | "readme": "DOCS.md", 6 | "name": "@tomic", 7 | "out": "data-browser/publish/docs" 8 | } 9 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/FolderDisplayStyle.ts: -------------------------------------------------------------------------------- 1 | import { Resource } from '@tomic/react'; 2 | 3 | export interface ViewProps { 4 | subResources: Map; 5 | onNewClick: () => void; 6 | showNewButton: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "sakamoto66.vscode-playwright-test-runner", 4 | "ms-vscode.vscode-typescript-next", 5 | "dbaeumer.vscode-eslint", 6 | "antfu.vite", 7 | "ms-playwright.playwright" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /data-browser/src/components/Dialog/dialogContext.ts: -------------------------------------------------------------------------------- 1 | import React, { createContext } from 'react'; 2 | 3 | export const DialogPortalContext = createContext< 4 | React.RefObject 5 | >(null!); 6 | 7 | export const DialogTreeContext = createContext(false); 8 | -------------------------------------------------------------------------------- /lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.build.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": ".", 7 | }, 8 | "include": [ 9 | "./src" 10 | ], 11 | "references": [] 12 | } 13 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "bracketSpacing": true, 7 | "useTabs": false, 8 | "arrowParens": "avoid", 9 | "jsxSingleQuote": true, 10 | "trailingComma": "all", 11 | "jsdocParser": true 12 | } 13 | -------------------------------------------------------------------------------- /data-browser/src/handlers/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import toast from 'react-hot-toast'; 2 | import { handleError } from '../helpers/loggingHandlers'; 3 | 4 | export const errorHandler = (e: Error) => { 5 | handleError(e); 6 | 7 | const message = e.message; 8 | 9 | toast.error(message); 10 | }; 11 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | PR Checklist: 2 | 3 | - [ ] Link to related issues: #number 4 | - [ ] Add changelog entry linking to issue 5 | - [ ] Add tests (if needed) 6 | - [ ] If dependent on server-side changes: link to PR on `atomic-data-rust` 7 | - [ ] If new feature: added in description / readme 8 | -------------------------------------------------------------------------------- /data-browser/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | testMatch: [ 4 | '**/__tests__/**/*.+(ts|tsx|js)', 5 | '**/?(*.)+(spec|test).+(ts|tsx|js)', 6 | ], 7 | transform: { 8 | '^.+\\.(ts|tsx)$': 'ts-jest', 9 | }, 10 | testEnvironment: 'node', 11 | }; 12 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | # @tomic/lib: The Atomic Data library for typescript / javascript 2 | 3 | [**docs**](https://atomicdata-dev.github.io/atomic-data-browser/docs/modules/_tomic_lib.html) 4 | 5 | Core typescript library for handling JSON-AD parsing, storing [Atomic Data](https://docs.atomicdata.dev/), signing Commits, and more. 6 | -------------------------------------------------------------------------------- /data-browser/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /data-browser/src/views/File/displayFileSize.ts: -------------------------------------------------------------------------------- 1 | const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 2 | 3 | export function displayFileSize(n: number) { 4 | let l = 0; 5 | 6 | while (n >= 1024 && ++l) { 7 | n = n / 1024; 8 | } 9 | 10 | return n.toFixed(n < 10 && l > 0 ? 1 : 0) + ' ' + units[l]; 11 | } 12 | -------------------------------------------------------------------------------- /data-browser/src/helpers/tauri.tsx: -------------------------------------------------------------------------------- 1 | // This application can be used in a Tauri context. 2 | 3 | declare global { 4 | interface Window { 5 | __TAURI_METADATA__: unknown; 6 | } 7 | } 8 | 9 | export function isRunningInTauri(): boolean { 10 | return ( 11 | typeof window !== 'undefined' && window.__TAURI_METADATA__ !== undefined 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /data-browser/src/components/Dropdown/DropdownTrigger.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface DropdownTriggerProps { 4 | onClick: (event: React.MouseEvent) => void; 5 | menuId: string; 6 | isActive: boolean; 7 | } 8 | 9 | export type DropdownTriggerRenderFunction = React.ForwardRefRenderFunction< 10 | HTMLButtonElement, 11 | DropdownTriggerProps 12 | >; 13 | -------------------------------------------------------------------------------- /data-browser/src/components/datatypes/DateTime.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | date: Date; 5 | }; 6 | 7 | /** Renders a Date value */ 8 | export function DateTime({ date }: Props): JSX.Element { 9 | return ( 10 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /data-browser/src/components/SideBar/SideBarHeader.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const SideBarHeader = styled('div')` 4 | margin-top: ${props => props.theme.margin}rem; 5 | margin-bottom: 0.5rem; 6 | padding-left: ${props => props.theme.margin}rem; 7 | padding-right: 0.7rem; 8 | font-size: 1.4rem; 9 | font-weight: bold; 10 | display: flex; 11 | `; 12 | -------------------------------------------------------------------------------- /data-browser/src/helpers/useFocus.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | /** Hook for programmaticall setting focus */ 4 | export const useFocus = (): [React.RefObject, () => void] => { 5 | const htmlElRef = useRef(null); 6 | 7 | const setFocus = () => { 8 | htmlElRef.current && htmlElRef.current.focus(); 9 | }; 10 | 11 | return [htmlElRef, setFocus]; 12 | }; 13 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useEffectOnce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | /** Logic is called only once - also in React 18! */ 4 | export function useEffectOnce(effect: () => (() => void) | void) { 5 | const called = useRef(false); 6 | useEffect(() => { 7 | if (!called.current) { 8 | called.current = true; 9 | 10 | return effect(); 11 | } 12 | }, []); 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | docs 4 | publish 5 | .env 6 | .husky 7 | .nohup 8 | */lib 9 | */dist 10 | */dev-dist 11 | */yarn-error.log 12 | test-results 13 | */nohup.out 14 | */yarn-error.log 15 | lib/coverage 16 | react/coverage 17 | data-browser/coverage 18 | .yarn/* 19 | !.yarn/cache 20 | !.yarn/patches 21 | !.yarn/plugins 22 | !.yarn/releases 23 | !.yarn/sdks 24 | !.yarn/versions 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /lib/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | testMatch: [ 4 | '**/__tests__/**/*.+(ts|tsx|js)', 5 | '**/?(*.)+(spec|test).+(ts|tsx|js)', 6 | ], 7 | transform: { 8 | '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true }], 9 | }, 10 | testEnvironment: 'node', 11 | moduleNameMapper: { 12 | '(.+)\\.js': '$1' 13 | }, 14 | extensionsToTreatAsEsm: ['.ts'] 15 | }; 16 | -------------------------------------------------------------------------------- /lib/src/search.test.ts: -------------------------------------------------------------------------------- 1 | import { escapeTantivyKey } from './search.js'; 2 | 3 | const testTuples = [ 4 | ['https://test', 'https\\://test'], 5 | ['https://test.com', 'https\\://test\\.com'], 6 | ]; 7 | 8 | describe('search.ts', () => { 9 | it('Handles resources without an ID', () => { 10 | for (const [input, output] of testTuples) { 11 | expect(escapeTantivyKey(input)).toBe(output); 12 | } 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /data-browser/tests/test-config.ts: -------------------------------------------------------------------------------- 1 | import type { TestConfig } from './e2e.spec.js'; 2 | const demoFileName = 'testimage.svg'; 3 | 4 | export const testConfig: TestConfig = { 5 | demoFileName, 6 | demoFile: `${process.cwd()}/tests/${demoFileName}`, 7 | demoInviteName: 'document demo', 8 | serverUrl: process.env.SERVER_URL || 'http://localhost:9883', 9 | frontEndUrl: 'http://localhost:5173', 10 | initialTest: false, 11 | }; 12 | -------------------------------------------------------------------------------- /data-browser/src/routes/Sandbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ContainerFull } from '../components/Containers'; 3 | 4 | export function Sandbox(): JSX.Element { 5 | return ( 6 |
7 | 8 |

Sandbox

9 |

10 | Welcome to the sandbox. This is a place to test components in 11 | isolation. 12 |

13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /data-browser/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './App'; 5 | 6 | /** 7 | * Top level React node of the Application. Keep this one empty (no providers), 8 | * as the Testing library imports the App component 9 | */ 10 | const root = createRoot(document.getElementById('root')!); 11 | root.render( 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@tomic/lib/*": [ 5 | "./lib/src/*" 6 | ], 7 | "@tomic/react/*": [ 8 | "./react/src/*" 9 | ] 10 | }, 11 | }, 12 | "files": [], 13 | "references": [ 14 | { 15 | "path": "lib" 16 | }, 17 | { 18 | "path": "react" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /data-browser/src/components/Detail.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | /** Small component showing some metadata. They appear next to each other. */ 4 | export const Detail = styled.div` 5 | display: inline-flex; 6 | align-items: center; 7 | gap: 1ch; 8 | margin-right: 2rem; 9 | `; 10 | 11 | /** A wrapper for the Detail component . */ 12 | export const Details = styled.div` 13 | font-style: italic; 14 | margin-bottom: 0.5rem; 15 | margin-top: -0.5rem; 16 | `; 17 | -------------------------------------------------------------------------------- /react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.build.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "lib": [ 7 | "ES6", 8 | "ES7", 9 | "ESNext", 10 | "DOM" 11 | ], 12 | "rootDir": ".", 13 | "jsx": "react" 14 | }, 15 | "include": [ 16 | "./src" 17 | ], 18 | "references": [ 19 | { 20 | "path": "../lib" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /data-browser/src/routes/History/HistoryViewProps.ts: -------------------------------------------------------------------------------- 1 | import { Resource, Version } from '@tomic/react'; 2 | 3 | export type GroupedVersions = { 4 | [key: string]: Version[]; 5 | }; 6 | 7 | export interface HistoryViewProps { 8 | resource: Resource; 9 | groupedVersions: GroupedVersions; 10 | selectedVersion: Version | undefined; 11 | isCurrentVersion: boolean; 12 | onNextVersion: () => void; 13 | onPreviousVersion: () => void; 14 | onSelectVersion: (version: Version) => void; 15 | onVersionAccept: () => void; 16 | } 17 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useCombineRefs.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | /** 4 | * Returns a callback ref that sets all given refs to the same passed in node. 5 | * Usefull if you want multiple refs to reference the same dom element. 6 | */ 7 | export function useCombineRefs( 8 | refs: React.MutableRefObject[], 9 | deps: unknown[] = [], 10 | ): (node: T) => void { 11 | return useCallback((node: T) => { 12 | for (const ref of refs) { 13 | ref.current = node; 14 | } 15 | }, deps); 16 | } 17 | -------------------------------------------------------------------------------- /data-browser/src/helpers/transition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | css, 3 | DefaultTheme, 4 | FlattenInterpolation, 5 | ThemeProps, 6 | } from 'styled-components'; 7 | 8 | export function transition( 9 | ...properties: string[] 10 | ): FlattenInterpolation> { 11 | const interpolate = (theme: DefaultTheme) => 12 | properties 13 | .map(p => `${p} ${theme.animation.duration} ease-in-out`) 14 | .join(','); 15 | 16 | return css` 17 | transition: ${({ theme }) => interpolate(theme)}; 18 | `; 19 | } 20 | -------------------------------------------------------------------------------- /data-browser/src/helpers/useMedia.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const useViewport = (): number => { 4 | const [width, setWidth] = React.useState(window.innerWidth); 5 | 6 | React.useEffect(() => { 7 | const handleWindowResize = () => setWidth(window.innerWidth); 8 | window.addEventListener('resize', handleWindowResize); 9 | 10 | return () => window.removeEventListener('resize', handleWindowResize); 11 | }, []); 12 | 13 | // Return the width so we can use it in our components 14 | return width; 15 | }; 16 | -------------------------------------------------------------------------------- /data-browser/src/routes/LocalRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | import ResourcePage from '../views/ResourcePage'; 4 | 5 | /** Show a resource where the domain matches the current domain */ 6 | function Local(): JSX.Element { 7 | const { pathname, search } = useLocation(); 8 | 9 | const subject = window.location.origin + pathname + search; 10 | 11 | // The key makes sure the component re-renders when it changes 12 | return ; 13 | } 14 | 15 | export default Local; 16 | -------------------------------------------------------------------------------- /data-browser/src/routes/paths.tsx: -------------------------------------------------------------------------------- 1 | export const paths = { 2 | agentSettings: '/app/agent', 3 | themeSettings: '/app/theme', 4 | serverSettings: '/app/server', 5 | new: '/app/new', 6 | shortcuts: '/app/shortcuts', 7 | search: '/app/search', 8 | share: '/app/share', 9 | show: '/app/show', 10 | token: '/app/token', 11 | data: '/app/data', 12 | edit: '/app/edit', 13 | about: '/app/about', 14 | import: '/app/import', 15 | history: '/app/history', 16 | allVersions: '/all-versions', 17 | sandbox: '/sandbox', 18 | fetchBookmark: '/fetch-bookmark', 19 | }; 20 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/GridItem/DefaultGridItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { AllPropsSimple } from '../../../components/AllPropsSimple'; 4 | import { GridItemViewProps } from './GridItemViewProps'; 5 | 6 | export function DefaultGridItem({ resource }: GridItemViewProps): JSX.Element { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | const DefaultGridWrapper = styled.div` 15 | padding: ${p => p.theme.margin}rem; 16 | pointer-events: none; 17 | `; 18 | -------------------------------------------------------------------------------- /data-browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.build.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | // We overwrite this so we don't have to use `.js` extensions in imports 6 | "moduleResolution": "node", 7 | "lib": [ 8 | "ES6", 9 | "ES7", 10 | "ESNext", 11 | "DOM" 12 | ], 13 | "outDir": "./dist", 14 | "strictNullChecks": true, 15 | }, 16 | "include": [ 17 | "./src", 18 | "./tests" 19 | ], 20 | "references": [ 21 | { 22 | "path": "../lib" 23 | }, 24 | { 25 | "path": "../react" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /data-browser/src/components/NewInstanceButton/NewInstanceButtonProps.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconType } from 'react-icons'; 3 | 4 | interface Props { 5 | /** URL of the Class to be instantiated */ 6 | klass: string; 7 | subtle?: boolean; 8 | icon?: boolean; 9 | IconComponent?: IconType; 10 | /** subject of the parent Resource, which will be passed to the form */ 11 | parent?: string; 12 | /** Give explicit label. If missing, uses the Shortname of the Class */ 13 | label?: string; 14 | className?: string; 15 | } 16 | 17 | export type NewInstanceButtonProps = React.PropsWithChildren; 18 | -------------------------------------------------------------------------------- /data-browser/src/components/datatypes/NestedResource.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Resource } from '@tomic/react'; 3 | import AllProps from '../AllProps'; 4 | import styled from 'styled-components'; 5 | 6 | type Props = { 7 | resource: Resource; 8 | }; 9 | 10 | /** Renders a Date value */ 11 | function Nestedresource({ resource }: Props): JSX.Element { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | const NestedWrapper = styled.div` 20 | margin-left: ${p => p.theme.margin}rem; 21 | `; 22 | 23 | export default Nestedresource; 24 | -------------------------------------------------------------------------------- /data-browser/src/components/SignInButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { paths } from '../routes/paths'; 4 | import { Button } from './Button'; 5 | 6 | /** 7 | * Button that currently links to the Agent Settings page. Should probably open 8 | * in a Modal. 9 | */ 10 | export function SignInButton() { 11 | const navigate = useNavigate(); 12 | 13 | return ( 14 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /data-browser/src/components/Dialog/DialogContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | import { DialogPortalContext } from './dialogContext'; 4 | 5 | export const DialogContainer: React.FC> = ({ 6 | children, 7 | }) => { 8 | const portalRef = useRef(null); 9 | 10 | return ( 11 | 12 | {children} 13 | 14 | 15 | ); 16 | }; 17 | 18 | const StyledDiv = styled.div` 19 | display: contents; 20 | `; 21 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/GridItem/BasicGridItem.tsx: -------------------------------------------------------------------------------- 1 | import { properties, useString } from '@tomic/react'; 2 | import React from 'react'; 3 | import { GridItemDescription, InnerWrapper } from './components'; 4 | import { GridItemViewProps } from './GridItemViewProps'; 5 | 6 | /** A simple view that only renders the description */ 7 | export function BasicGridItem({ resource }: GridItemViewProps): JSX.Element { 8 | const [description] = useString(resource, properties.description); 9 | 10 | return ( 11 | 12 | {description} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /data-browser/src/helpers/useNewRoute.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useNavigate } from 'react-router'; 3 | import { paths } from '../routes/paths'; 4 | 5 | function buildURL(parent?: string) { 6 | const params = new URLSearchParams({ 7 | ...(parent ? { parentSubject: parent } : {}), 8 | }); 9 | 10 | return `${paths.new}?${params.toString()}`; 11 | } 12 | 13 | export function useNewRoute(parent?: string) { 14 | const navigate = useNavigate(); 15 | 16 | const navigateToNewRoute = useCallback(() => { 17 | const url = buildURL(parent); 18 | navigate(url); 19 | }, [parent]); 20 | 21 | return navigateToNewRoute; 22 | } 23 | -------------------------------------------------------------------------------- /react/src/useCurrentAgent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useSyncExternalStore } from 'react'; 2 | import { Agent, StoreEvents } from '@tomic/lib'; 3 | import { useStore } from './index.js'; 4 | 5 | /** 6 | * A hook for using and adjusting the Agent in the store. 7 | */ 8 | export const useCurrentAgent = (): [ 9 | Agent | undefined, 10 | (agent?: Agent) => void, 11 | ] => { 12 | const store = useStore(); 13 | 14 | const subscribe = useCallback( 15 | (callback: () => void) => store.on(StoreEvents.AgentChanged, callback), 16 | [store], 17 | ); 18 | 19 | const agent = useSyncExternalStore(subscribe, store.getAgent); 20 | 21 | return [agent, store.setAgent]; 22 | }; 23 | -------------------------------------------------------------------------------- /data-browser/src/helpers/timeoutEffect.ts: -------------------------------------------------------------------------------- 1 | /** Sets a timeout and returns a cleanup function, ideal for using in a useEffect */ 2 | export function timeoutEffect(func: () => void, delay: number): () => void { 3 | const id = setTimeout(func, delay); 4 | 5 | return () => clearTimeout(id); 6 | } 7 | 8 | /** Runs multiple timeout effects and returns the cleanup functions combined into one */ 9 | export function timeoutEffects( 10 | ...args: Array<[func: () => void, delay: number]> 11 | ): () => void { 12 | const cleaners = args.map(([func, delay]) => timeoutEffect(func, delay)); 13 | 14 | return () => { 15 | for (const cleaner of cleaners) { 16 | cleaner(); 17 | } 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /data-browser/tests/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | use: { 5 | screenshot: 'only-on-failure', 6 | viewport: { width: 1200, height: 800 }, 7 | }, 8 | retries: 3, 9 | // projects: [ 10 | // { 11 | // name: 'chromium', 12 | // use: { ...devices['Desktop Chrome'] }, 13 | // }, 14 | // { 15 | // name: 'firefox', 16 | // use: { ...devices['Desktop Firefox'] }, 17 | // }, 18 | // { 19 | // name: 'webkit', 20 | // use: { ...devices['Desktop Safari'] }, 21 | // }, 22 | // ], 23 | fullyParallel: true, 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /data-browser/src/views/Card/CardViewProps.tsx: -------------------------------------------------------------------------------- 1 | import { Resource } from '@tomic/react'; 2 | 3 | export interface CardViewPropsBase { 4 | /** Maximum height, only basic details are shown */ 5 | small?: boolean; 6 | /** Show a highlight border */ 7 | highlight?: boolean; 8 | /** An HTML reference */ 9 | ref?: React.RefObject; 10 | /** 11 | * If you expect to render this card in the initial view (e.g. it's in the top 12 | * of some list) 13 | */ 14 | initialInView?: boolean; 15 | } 16 | 17 | /** The properties passed to every CardView */ 18 | export interface CardViewProps extends CardViewPropsBase { 19 | /** The full Resource to be displayed */ 20 | resource: Resource; 21 | } 22 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useFilePreviewSizeLimit.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useMediaQuery } from './useMediaQuery'; 3 | 4 | const DEFAULT_FILE_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MB 5 | const REDUCED_FILE_SIZE_LIMIT = 1024 * 100; // 100 KB 6 | 7 | export function useFilePreviewSizeLimit() { 8 | const [limit, setLimit] = useState(DEFAULT_FILE_SIZE_LIMIT); 9 | 10 | const prefersReducedData = useMediaQuery('(prefers-reduced-data: reduce)'); 11 | 12 | useEffect(() => { 13 | if (prefersReducedData) { 14 | setLimit(REDUCED_FILE_SIZE_LIMIT); 15 | } else { 16 | setLimit(DEFAULT_FILE_SIZE_LIMIT); 17 | } 18 | }, [prefersReducedData]); 19 | 20 | return limit; 21 | } 22 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useOnline.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** Returns true when the users device is connected to a network. */ 4 | export function useOnline(): boolean { 5 | const [online, setOnline] = useState(navigator.onLine); 6 | 7 | useEffect(() => { 8 | const handleOnline = () => setOnline(true); 9 | const handleOffline = () => setOnline(false); 10 | 11 | window.addEventListener('online', handleOnline); 12 | window.addEventListener('offline', handleOffline); 13 | 14 | return () => { 15 | window.removeEventListener('online', handleOnline); 16 | window.removeEventListener('offline', handleOffline); 17 | }; 18 | }, []); 19 | 20 | return online; 21 | } 22 | -------------------------------------------------------------------------------- /data-browser/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { Store, StoreEvents } from '@tomic/react'; 2 | import { saveAgentToLocalStorage } from '../helpers/agentStorage'; 3 | import { errorHandler } from './errorHandler'; 4 | import { 5 | buildSideBarNewResourceHandler, 6 | buildSideBarRemoveResourceHandler, 7 | } from './sideBarHandler'; 8 | 9 | export function registerHandlers(store: Store) { 10 | store.on( 11 | StoreEvents.ResourceManuallyCreated, 12 | buildSideBarNewResourceHandler(store), 13 | ); 14 | store.on( 15 | StoreEvents.ResourceRemoved, 16 | buildSideBarRemoveResourceHandler(store), 17 | ); 18 | store.on(StoreEvents.Error, errorHandler); 19 | store.on(StoreEvents.AgentChanged, saveAgentToLocalStorage); 20 | } 21 | -------------------------------------------------------------------------------- /data-browser/src/components/Dropdown/DefaultTrigger.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconButton } from '../IconButton/IconButton'; 3 | import { DropdownTriggerRenderFunction } from './DropdownTrigger'; 4 | 5 | export const buildDefaultTrigger = ( 6 | icon: React.ReactNode, 7 | title = 'Open menu', 8 | ButtonComp: typeof IconButton = IconButton, 9 | ): DropdownTriggerRenderFunction => { 10 | const Comp = ({ onClick, menuId }, ref: React.Ref) => ( 11 | 17 | {icon} 18 | 19 | ); 20 | 21 | Comp.DisplayName = 'DefaultTrigger'; 22 | 23 | return Comp; 24 | }; 25 | -------------------------------------------------------------------------------- /data-browser/src/helpers/agentStorage.tsx: -------------------------------------------------------------------------------- 1 | import { Agent } from '@tomic/react'; 2 | 3 | const AGENT_LOCAL_STORAGE_KEY = 'agent'; 4 | 5 | export function getAgentFromLocalStorage(): Agent | undefined { 6 | const secret = localStorage.getItem(AGENT_LOCAL_STORAGE_KEY); 7 | 8 | if (!secret) { 9 | return undefined; 10 | } 11 | 12 | try { 13 | return Agent.fromSecret(secret); 14 | } catch (e) { 15 | console.error(e); 16 | 17 | return undefined; 18 | } 19 | } 20 | 21 | export function saveAgentToLocalStorage(agent: Agent | undefined): void { 22 | if (agent) { 23 | localStorage.setItem(AGENT_LOCAL_STORAGE_KEY, agent.buildSecret()); 24 | } else { 25 | localStorage.removeItem(AGENT_LOCAL_STORAGE_KEY); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** Watches a media query and returns a statefull result. */ 4 | export function useMediaQuery(query: string, initial = false): boolean { 5 | const [matches, setMatches] = useState(initial); 6 | 7 | useEffect(() => { 8 | if (!window.matchMedia) { 9 | return; 10 | } 11 | 12 | const listener = (e: MediaQueryListEvent) => { 13 | setMatches(e.matches); 14 | }; 15 | 16 | const queryList = window.matchMedia(query); 17 | setMatches(queryList.matches); 18 | 19 | queryList.addEventListener('change', listener); 20 | 21 | return () => queryList.removeEventListener('change', listener); 22 | }, []); 23 | 24 | return matches; 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/agent.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Agent } from './index.js'; 3 | 4 | describe('Agent', () => { 5 | it('Constructs valid ', async () => { 6 | const validPrivateKey = 'CapMWIhFUT+w7ANv9oCPqrHrwZpkP2JhzF9JnyT6WcI='; 7 | const validSubject = 8 | 'https://atomicdata.dev/agents/PLwTOXVvQdHYpaLEq5IozLNeUBdXMVchKjFwFfamBlo='; 9 | const validAgent = () => new Agent(validPrivateKey, validSubject); 10 | expect(validAgent).not.to.throw(); 11 | // Can't get this to throw yet 12 | // const invalidAgentSignature = () => new Agent(validSubject, 'ugh'); 13 | // expect(invalidAgentSignature).to.throw(); 14 | const invalidAgentUrl = () => new Agent(validPrivateKey, 'not_a_url'); 15 | expect(invalidAgentUrl).to.throw(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "start", 7 | "problemMatcher": [ 8 | "$tsc-watch" 9 | ], 10 | "label": "run data-browser dev server (pnpm start)", 11 | "detail": "pnpm workspace @tomic/data-browser start", 12 | "isBackground": true, 13 | "group": "build" 14 | }, 15 | { 16 | "type": "npm", 17 | "script": "test", 18 | "problemMatcher": [ 19 | "$tsc-watch" 20 | ], 21 | "label": "test data-browser", 22 | "detail": "pnpm test", 23 | "isBackground": true, 24 | "group": "test" 25 | }, 26 | { 27 | "type": "shell", 28 | "command": "./build_server.sh", 29 | "label": "build server JS assets", 30 | "isBackground": true, 31 | "group": "build" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /data-browser/src/components/Containers.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | /** Centered column */ 4 | export const ContainerNarrow = styled.div` 5 | max-width: ${props => props.theme.containerWidth}rem; 6 | margin: auto; 7 | padding: ${props => props.theme.margin}rem; 8 | // Extra space for the navbar below 9 | padding-bottom: 10rem; 10 | `; 11 | 12 | export const ContainerWide = styled.div` 13 | width: min(100%, ${props => props.theme.containerWidthWide}); 14 | margin: auto; 15 | padding: ${props => props.theme.margin}rem; 16 | // Extra space for the navbar below 17 | padding-bottom: 10rem; 18 | `; 19 | 20 | /** Full-page wrapper */ 21 | export const ContainerFull = styled.div` 22 | padding: ${props => props.theme.margin}rem; 23 | padding-bottom: 10rem; 24 | `; 25 | -------------------------------------------------------------------------------- /data-browser/src/components/ResourceContextMenu/MenuBarDropdownTrigger.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ButtonBar } from '../Button'; 3 | import { FaEllipsisV } from 'react-icons/fa'; 4 | import { DropdownTriggerRenderFunction } from '../Dropdown/DropdownTrigger'; 5 | import { shortcuts } from '../HotKeyWrapper'; 6 | 7 | export const MenuBarDropdownTrigger: DropdownTriggerRenderFunction = ( 8 | { onClick, isActive, menuId }, 9 | ref, 10 | ) => ( 11 | 21 | 22 | 23 | ); 24 | 25 | MenuBarDropdownTrigger.displayName = 'MenuBarDropdownTrigger'; 26 | -------------------------------------------------------------------------------- /data-browser/src/routes/ShowRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Client } from '@tomic/react'; 3 | import ResourcePage from '../views/ResourcePage'; 4 | import { useCurrentSubject } from '../helpers/useCurrentSubject'; 5 | import { Search } from './SearchRoute'; 6 | import { About } from './AboutRoute'; 7 | 8 | /** Renders either the Welcome page, an Individual resource, or search results. */ 9 | const Show: React.FunctionComponent = () => { 10 | // Value shown in navbar, after Submitting 11 | const [subject] = useCurrentSubject(); 12 | 13 | if (subject === undefined || subject === '') { 14 | return ; 15 | } 16 | 17 | if (Client.isValidSubject(subject)) { 18 | return ; 19 | } else { 20 | return ; 21 | } 22 | }; 23 | 24 | export default Show; 25 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useFile.ts: -------------------------------------------------------------------------------- 1 | import { properties, Resource, useNumber, useString } from '@tomic/react'; 2 | import { useCallback } from 'react'; 3 | 4 | export function useFileInfo(resource: Resource) { 5 | const [downloadUrl] = useString(resource, properties.file.downloadUrl); 6 | const [mimeType] = useString(resource, properties.file.mimetype); 7 | 8 | const [bytes] = useNumber(resource, properties.file.filesize); 9 | 10 | const downloadFile = useCallback(() => { 11 | window.open(downloadUrl); 12 | }, [downloadUrl]); 13 | 14 | if ( 15 | downloadUrl === undefined || 16 | mimeType === undefined || 17 | bytes === undefined 18 | ) { 19 | throw new Error('File resource is missing properties'); 20 | } 21 | 22 | return { 23 | downloadFile, 24 | downloadUrl, 25 | bytes, 26 | mimeType, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/GridItem/DocumentGridItem.tsx: -------------------------------------------------------------------------------- 1 | import { properties, useArray, useResource, useString } from '@tomic/react'; 2 | import React from 'react'; 3 | import Markdown from '../../../components/datatypes/Markdown'; 4 | import { GridItemDescription, InnerWrapper } from './components'; 5 | import { GridItemViewProps } from './GridItemViewProps'; 6 | 7 | export function DocumentGridItem({ resource }: GridItemViewProps): JSX.Element { 8 | const [elements] = useArray(resource, properties.document.elements); 9 | const firstElementResource = useResource(elements[0]); 10 | const [text] = useString(firstElementResource, properties.description); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/NewForm/SubjectField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Field from '../Field'; 3 | import { InputStyled, InputWrapper } from '../InputStyles'; 4 | 5 | export interface SubjectFieldProps { 6 | error?: Error; 7 | value: string; 8 | onChange: (value: string) => void; 9 | } 10 | 11 | export const SubjectField: React.FC = ({ 12 | error, 13 | value, 14 | onChange, 15 | }) => ( 16 | 21 | 22 | onChange(e.target.value)} 25 | placeholder={'URL of the new resource...'} 26 | /> 27 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /data-browser/src/views/File/useFileImageTransitionStyles.ts: -------------------------------------------------------------------------------- 1 | import { getTransitionName } from '../../helpers/transitionName'; 2 | import { useGlobalStylesWhileMounted } from '../../hooks/useGlobalStylesWhileMounted'; 3 | 4 | export function useFileImageTransitionStyles(subject: string) { 5 | const name = getTransitionName('file-image', subject); 6 | 7 | useGlobalStylesWhileMounted(` 8 | ::view-transition-old(${name}), 9 | ::view-transition-new(${name}) { 10 | mix-blend-mode: normal; 11 | height: 100%; 12 | overflow: clip; 13 | } 14 | 15 | ::view-transition-old(${name}) { 16 | object-fit: contain; 17 | } 18 | 19 | ::view-transition-new(${name}) { 20 | animation: none; 21 | object-fit: cover; 22 | } 23 | 24 | `); 25 | 26 | return { viewTransitionName: name } as Record; 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/truncate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Makes URLs shorter and removes the schema. Hides the hostname if it's equal 3 | * to the window hostname 4 | */ 5 | export function truncateUrl( 6 | url: string, 7 | num: number, 8 | truncateBack?: boolean, 9 | ): string { 10 | // Remove the schema, the https:// part 11 | let noSchema = url.replace(/(^\w+:|^)\/\//, ''); 12 | 13 | if ( 14 | typeof window !== 'undefined' && 15 | window?.location && 16 | noSchema.startsWith(window.location.hostname) 17 | ) { 18 | noSchema = noSchema.slice(window.location.hostname.length); 19 | } 20 | 21 | if (noSchema.length <= num) { 22 | return noSchema; 23 | } 24 | 25 | if (truncateBack) { 26 | const tooMuch = noSchema.length - num; 27 | 28 | return '...' + noSchema.slice(tooMuch); 29 | } 30 | 31 | return noSchema.slice(0, num) + '...'; 32 | } 33 | -------------------------------------------------------------------------------- /react/src/useChildren.ts: -------------------------------------------------------------------------------- 1 | // Sorry for the name of this 2 | import { properties, Resource } from '@tomic/lib'; 3 | import { useEffect } from 'react'; 4 | import { useArray, useResource, useStore } from './index.js'; 5 | 6 | /** Creates a Collection and returns all children */ 7 | export const useChildren = (resource: Resource) => { 8 | const store = useStore(); 9 | const childrenUrl = resource.getChildrenCollection(); 10 | const childrenCollection = useResource(childrenUrl); 11 | const [children] = useArray( 12 | childrenCollection, 13 | properties.collection.members, 14 | ); 15 | 16 | // Because collections are not invalidated serverside at the moment we need to fetch it on mount in order to show up to date data 17 | useEffect(() => { 18 | store.fetchResourceFromServer(childrenUrl); 19 | }, [store]); 20 | 21 | return children; 22 | }; 23 | -------------------------------------------------------------------------------- /data-browser/src/components/ClassDetail.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { properties, Resource, useString } from '@tomic/react'; 3 | import { ResourceInline } from '../views/ResourceInline'; 4 | import { Detail } from './Detail'; 5 | import { getIconForClass } from '../views/FolderPage/iconMap'; 6 | 7 | type Props = { 8 | resource: Resource; 9 | }; 10 | 11 | /** Renders the is-a Class for some resource */ 12 | export function ClassDetail({ resource }: Props): JSX.Element { 13 | const [klass] = useString(resource, properties.isA); 14 | 15 | return ( 16 | 17 | {klass && ( 18 | 19 | <> 20 | {'is a '} 21 | {getIconForClass(klass)} 22 | 23 | 24 | 25 | )} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /data-browser/src/views/Card/ElementCard.tsx: -------------------------------------------------------------------------------- 1 | import { urls, useResource, useString, useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | import { AtomicLink } from '../../components/AtomicLink'; 4 | import Markdown from '../../components/datatypes/Markdown'; 5 | import { CardViewProps } from './CardViewProps'; 6 | 7 | export function ElementCard({ resource }: CardViewProps): JSX.Element { 8 | const [documentSubject] = useString(resource, urls.properties.parent); 9 | const document = useResource(documentSubject); 10 | const [documentTitle] = useTitle(document); 11 | 12 | const [text] = useString(resource, urls.properties.description); 13 | 14 | return ( 15 | <> 16 | 17 |

{documentTitle}

18 |
19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/GridItem/BookmarkGridItem.tsx: -------------------------------------------------------------------------------- 1 | import { properties, useString } from '@tomic/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import { BasicGridItem } from './BasicGridItem'; 5 | import { InnerWrapper } from './components'; 6 | import { GridItemViewProps } from './GridItemViewProps'; 7 | 8 | export function BookmarkGridItem({ resource }: GridItemViewProps): JSX.Element { 9 | const [imageUrl] = useString(resource, properties.bookmark.imageUrl); 10 | 11 | if (!imageUrl) { 12 | return ; 13 | } 14 | 15 | return ( 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | const Image = styled.img` 23 | width: 100%; 24 | height: 100%; 25 | object-fit: cover; 26 | object-position: center; 27 | `; 28 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useGlobalStylesWhileMounted.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useId } from 'react'; 2 | 3 | const getNode = (id: string) => { 4 | const existingNode = document.getElementById(id); 5 | 6 | if (existingNode) { 7 | return existingNode; 8 | } 9 | 10 | const node = document.createElement('style'); 11 | node.id = id; 12 | document.head.appendChild(node); 13 | 14 | return node; 15 | }; 16 | 17 | /** 18 | * Add a style element to the head with the given cssText while the component is mounted. 19 | * @param cssText CSS Styles to be added to the head. 20 | */ 21 | export function useGlobalStylesWhileMounted(cssText: string) { 22 | const id = useId(); 23 | 24 | useEffect(() => { 25 | const node = getNode(id); 26 | 27 | node.innerHTML = cssText; 28 | 29 | return () => { 30 | document.head.removeChild(node); 31 | }; 32 | }, [cssText]); 33 | } 34 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/InputBoolean.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useBoolean } from '@tomic/react'; 3 | import { InputProps } from './ResourceField'; 4 | import { ErrMessage, InputStyled } from './InputStyles'; 5 | 6 | export default function InputBoolean({ 7 | resource, 8 | property, 9 | ...props 10 | }: InputProps): JSX.Element { 11 | const [err, setErr] = useState(undefined); 12 | const [value, setValue] = useBoolean(resource, property.subject, { 13 | handleValidationError: setErr, 14 | }); 15 | 16 | function handleUpdate(e) { 17 | setValue(e.target.checked); 18 | } 19 | 20 | return ( 21 | <> 22 | 28 | {err && {err.message}} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /data-browser/src/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import { Resource, useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | import { AtomicLink } from './AtomicLink'; 4 | 5 | interface PageTitleProps { 6 | /** Put in front of the subject's name */ 7 | prefix?: string; 8 | resource: Resource; 9 | /** Renders the Resources title as a clickable link */ 10 | link?: boolean; 11 | } 12 | 13 | /** 14 | * An H1 heading title with the subject's name. Optionally makes it a clickable 15 | * link or adds a prefix. Use `EditableTitle` if you need editing capabilities. 16 | */ 17 | export function Title({ resource, prefix, link }: PageTitleProps): JSX.Element { 18 | const [title] = useTitle(resource); 19 | 20 | return ( 21 |

22 | {prefix && `${prefix} `} 23 | {link ? ( 24 | {title} 25 | ) : ( 26 | title 27 | )} 28 |

29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /data-browser/src/routes/TokenRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ContainerNarrow } from '../components/Containers'; 3 | import { CodeBlock } from '../components/CodeBlock'; 4 | import { createAuthentication, useServerURL } from '@tomic/react'; 5 | import { useSettings } from '../helpers/AppSettings'; 6 | 7 | /** Lets user create bearer tokens */ 8 | export const TokenRoute: React.FunctionComponent = () => { 9 | const [token, setToken] = React.useState(''); 10 | const { agent } = useSettings(); 11 | const [server] = useServerURL(); 12 | React.useEffect(() => { 13 | async function getToken() { 14 | if (agent) { 15 | const json = await createAuthentication(server, agent); 16 | setToken(btoa(JSON.stringify(json))); 17 | } 18 | } 19 | 20 | getToken(); 21 | }, [agent]); 22 | 23 | return ( 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /react/src/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | // T is a generic type for value parameter, our case this will be string 4 | export function useDebounce(value: T, delay: number): T { 5 | // State and setters for debounced value 6 | const [debouncedValue, setDebouncedValue] = useState(value); 7 | 8 | useEffect( 9 | () => { 10 | // Update debounced value after delay 11 | const handler = setTimeout(() => { 12 | setDebouncedValue(value); 13 | }, delay); 14 | 15 | // Cancel the timeout if value changes (also on delay change or unmount) 16 | // This is how we prevent debounced value from updating if value is changed ... 17 | // .. within the delay period. Timeout gets cleared and restarted. 18 | return () => { 19 | clearTimeout(handler); 20 | }; 21 | }, 22 | [value, delay], // Only re-call effect if value or delay changes 23 | ); 24 | 25 | return debouncedValue; 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [ 3 | 80 4 | ], 5 | "search.exclude": { 6 | "**/.git": true, 7 | "**/node_modules": true, 8 | "**/build": true, 9 | "**/coverage": true, 10 | "**/dist": true, 11 | }, 12 | "editor.formatOnSave": true, 13 | "eslint.validate": [ 14 | "javascript", 15 | "javascriptreact", 16 | "typescript", 17 | "typescriptreact" 18 | ], 19 | "typescript.preferences.importModuleSpecifierEnding": "minimal", 20 | "eslint.alwaysShowStatus": true, 21 | "eslint.format.enable": true, 22 | "eslint.lintTask.enable": true, 23 | "eslint.packageManager": "pnpm", 24 | "eslint.quiet": true, 25 | "editor.codeActionsOnSave": { 26 | "source.fixAll.eslint": true, 27 | }, 28 | "jest.enableInlineErrorMessages": true, 29 | "jest.showCoverageOnLoad": true, 30 | "jest.runAllTestsFirst": false, 31 | "eslint.workingDirectories": [ 32 | "./data-browser", 33 | "./react", 34 | "./lib" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /data-browser/src/components/SideBar/menuItems.tsx: -------------------------------------------------------------------------------- 1 | import { FaCog, FaInfo, FaKeyboard, FaUser } from 'react-icons/fa'; 2 | import React from 'react'; 3 | import { paths } from '../../routes/paths'; 4 | import { SideBarMenuItemProps } from './SideBarMenuItem'; 5 | 6 | export const appMenuItems: SideBarMenuItemProps[] = [ 7 | { 8 | icon: , 9 | label: 'User Settings', 10 | helper: 'See and edit the current Agent / User (u)', 11 | path: paths.agentSettings, 12 | }, 13 | { 14 | icon: , 15 | label: 'Theme Settings', 16 | helper: 'Edit the theme, current Agent, and more. (t)', 17 | path: paths.themeSettings, 18 | }, 19 | { 20 | icon: , 21 | label: 'Keyboard Shortcuts', 22 | helper: 'View the keyboard shortcuts (?)', 23 | path: paths.shortcuts, 24 | }, 25 | { 26 | icon: , 27 | label: 'About', 28 | helper: 'Welcome page, tells about this app', 29 | path: paths.about, 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /data-browser/src/helpers/debounce.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-types 2 | const debounceMap = new Map(); 3 | 4 | /** 5 | * Debounces a function, Note: this debounces per function, if you pass in a new 6 | * function it will not work. If you debounce the exact same function elsewhere 7 | * it will overwite the same debounce timer as this one. 8 | * 9 | * @param fn The function to debounce 10 | * @param delay The delay in milliseconds (defaults to 500) 11 | */ 12 | // eslint-disable-next-line @typescript-eslint/ban-types 13 | export function debounce(fn: T, delay = 500): T { 14 | const debouncedFn = (...args: unknown[]) => { 15 | const debounceId = debounceMap.get(fn); 16 | 17 | if (debounceId) { 18 | window.clearTimeout(debounceId); 19 | } 20 | 21 | debounceMap.set( 22 | fn, 23 | window.setTimeout(() => fn(...args), delay), 24 | ); 25 | }; 26 | 27 | return debouncedFn as unknown as T; 28 | } 29 | -------------------------------------------------------------------------------- /data-browser/src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | const loadingAnimation = keyframes` 4 | from { 5 | background-color: var(--loader-bg-from); 6 | } 7 | to { 8 | background-color: var(--loader-bg-to); 9 | } 10 | `; 11 | 12 | export const LoaderInline = styled.span` 13 | --loader-bg-from: ${p => p.theme.colors.bg1}; 14 | --loader-bg-to: ${p => p.theme.colors.bg}; 15 | background-color: ${p => p.theme.colors.bg1}; 16 | border-radius: ${p => p.theme.radius}; 17 | animation: ${loadingAnimation} 0.8s infinite ease-in-out alternate; 18 | width: 100%; 19 | height: 1rem; 20 | `; 21 | 22 | export const LoaderBlock = styled.div` 23 | --loader-bg-from: ${p => p.theme.colors.bg1}; 24 | --loader-bg-to: ${p => p.theme.colors.bg}; 25 | background-color: ${p => p.theme.colors.bg1}; 26 | border-radius: ${p => p.theme.radius}; 27 | animation: ${loadingAnimation} 0.8s infinite ease-in-out alternate; 28 | width: 100%; 29 | height: 100%; 30 | `; 31 | -------------------------------------------------------------------------------- /data-browser/src/routes/History/VersionTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Version, useResource, useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | import { AtomicLink } from '../../components/AtomicLink'; 4 | 5 | const formatter = new Intl.DateTimeFormat('default', { 6 | month: 'long', 7 | year: 'numeric', 8 | day: 'numeric', 9 | hour: 'numeric', 10 | minute: 'numeric', 11 | second: 'numeric', 12 | }); 13 | 14 | export interface VersionTitleProps { 15 | version: Version; 16 | } 17 | export function VersionTitle({ version }: VersionTitleProps): JSX.Element { 18 | const signer = useResource(version.commit.signer); 19 | const [signerName] = useTitle(signer); 20 | 21 | const date = new Date(version.commit.createdAt); 22 | const formattedDate = formatter.format(date); 23 | 24 | return ( 25 | 26 | Editted by{' '} 27 | {signerName} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /data-browser/src/components/SideBar/SideBarItem.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export interface SideBarItemProps { 4 | disabled?: boolean; 5 | } 6 | 7 | /** SideBarItem should probably be wrapped in an AtomicLink for optimal behavior */ 8 | // eslint-disable-next-line prettier/prettier 9 | export const SideBarItem = styled('span')` 10 | display: flex; 11 | min-height: ${props => props.theme.margin * 0.5 + 1}rem; 12 | align-items: center; 13 | justify-content: flex-start; 14 | color: ${p => (p.disabled ? p.theme.colors.main : p.theme.colors.textLight)}; 15 | padding: 0.2rem; 16 | padding-left: 1rem; 17 | text-overflow: ellipsis; 18 | text-decoration: none; 19 | border-radius: ${p => p.theme.radius}; 20 | 21 | &:hover, 22 | &:focus { 23 | background-color: ${p => p.theme.colors.bg1}; 24 | color: ${p => (p.disabled ? p.theme.colors.main : p.theme.colors.text)}; 25 | } 26 | &:active { 27 | background-color: ${p => p.theme.colors.bg2}; 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /data-browser/src/views/Article/ArticleCard.tsx: -------------------------------------------------------------------------------- 1 | import { properties, useString, useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import { AtomicLink } from '../../components/AtomicLink'; 5 | import { markdownToPlainText } from '../../helpers/markdown'; 6 | import { CardViewProps } from '../Card/CardViewProps'; 7 | 8 | export function ArticleCard({ resource }: CardViewProps): JSX.Element { 9 | const [title] = useTitle(resource); 10 | 11 | const [description] = useString(resource, properties.description); 12 | const truncated = markdownToPlainText(description ?? '').slice(0, 200); 13 | 14 | return ( 15 |
16 | 17 | {title} 18 | 19 |

{truncated}...

20 |
21 | ); 22 | } 23 | 24 | const Title = styled.h2` 25 | white-space: nowrap; 26 | text-overflow: ellipsis; 27 | width: 100%; 28 | overflow: hidden; 29 | font-size: 1.3rem; 30 | `; 31 | -------------------------------------------------------------------------------- /data-browser/src/components/FilePill.tsx: -------------------------------------------------------------------------------- 1 | import { useResource, useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | 4 | import { AtomicLink } from './AtomicLink'; 5 | import styled from 'styled-components'; 6 | 7 | interface FilePillProps { 8 | subject: string; 9 | } 10 | 11 | /** Small preview of a file */ 12 | function FilePill({ subject }: FilePillProps): JSX.Element { 13 | const resource = useResource(subject); 14 | const [title] = useTitle(resource); 15 | 16 | return ( 17 | 18 | 19 | {title} 20 | 21 | 22 | ); 23 | } 24 | 25 | const FilePillStyled = styled.div` 26 | display: inline-flex; 27 | border: solid 1px ${t => t.theme.colors.main}; 28 | border-radius: ${t => t.theme.radius}; 29 | padding: 0.4rem; 30 | margin-bottom: ${t => t.theme.margin}rem; 31 | margin-right: ${t => t.theme.margin}rem; 32 | `; 33 | 34 | export default FilePill; 35 | -------------------------------------------------------------------------------- /data-browser/src/helpers/formatTimeAgo.ts: -------------------------------------------------------------------------------- 1 | const formatter = new Intl.RelativeTimeFormat(undefined, { 2 | numeric: 'auto', 3 | }); 4 | 5 | const DIVISIONS: { 6 | amount: number; 7 | name: Intl.RelativeTimeFormatUnit; 8 | }[] = [ 9 | { amount: 60, name: 'seconds' }, 10 | { amount: 60, name: 'minutes' }, 11 | { amount: 24, name: 'hours' }, 12 | { amount: 7, name: 'days' }, 13 | { amount: 4.34524, name: 'weeks' }, 14 | { amount: 12, name: 'months' }, 15 | { amount: Number.POSITIVE_INFINITY, name: 'years' }, 16 | ]; 17 | 18 | // https://blog.webdevsimplified.com/2020-07/relative-time-format/ 19 | export function formatTimeAgo(date: Date): string | undefined { 20 | let duration = (date.getTime() - new Date().getTime()) / 1000; 21 | 22 | for (let i = 0; i <= DIVISIONS.length; i++) { 23 | const division = DIVISIONS[i]; 24 | 25 | if (Math.abs(duration) < division.amount) { 26 | return formatter.format(Math.round(duration), division.name); 27 | } 28 | 29 | duration /= division.amount; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /data-browser/src/views/File/FileCard.tsx: -------------------------------------------------------------------------------- 1 | import { useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | 4 | import { AtomicLink } from '../../components/AtomicLink'; 5 | import { Row } from '../../components/Row'; 6 | import { useFileInfo } from '../../hooks/useFile'; 7 | import { CardViewProps } from '../Card/CardViewProps'; 8 | import { DownloadIconButton } from './DownloadButton'; 9 | import { FilePreview } from './FilePreview'; 10 | 11 | function FileCard({ resource }: CardViewProps): JSX.Element { 12 | const [title] = useTitle(resource); 13 | const { downloadFile, bytes } = useFileInfo(resource); 14 | 15 | return ( 16 | 17 | 18 | 19 |

{title}

20 |
21 | 22 |
23 | 24 |
25 | ); 26 | } 27 | 28 | export default FileCard; 29 | -------------------------------------------------------------------------------- /data-browser/src/routes/History/useVersions.ts: -------------------------------------------------------------------------------- 1 | import { Resource, Version, useStore } from '@tomic/react'; 2 | import { useState, useEffect } from 'react'; 3 | import { dedupeVersions } from './versionHelpers'; 4 | 5 | export interface UseVersionsResult { 6 | versions: Version[]; 7 | loading: boolean; 8 | error: Error | undefined; 9 | } 10 | 11 | export function useVersions(resource: Resource): UseVersionsResult { 12 | const [versions, setVersions] = useState([]); 13 | const store = useStore(); 14 | const [loading, setLoading] = useState(true); 15 | const [error, setError] = useState(undefined); 16 | 17 | useEffect(() => { 18 | resource 19 | .getHistory(store) 20 | .then(history => { 21 | setVersions(dedupeVersions(history)); 22 | }) 23 | .catch(e => { 24 | setError(e); 25 | }) 26 | .finally(() => { 27 | setLoading(false); 28 | }); 29 | }, [resource.getSubject()]); 30 | 31 | return { versions, loading, error }; 32 | } 33 | -------------------------------------------------------------------------------- /data-browser/src/helpers/markdown.ts: -------------------------------------------------------------------------------- 1 | export function truncateMarkdown(value: string, length: number): string { 2 | if (value.length <= length) { 3 | return value; 4 | } 5 | 6 | const head = value.slice(0, length); 7 | 8 | if (head.endsWith('\n')) { 9 | return head + '...'; 10 | } 11 | 12 | const tail = value.slice(length); 13 | const firstNewLine = tail.indexOf('\n'); 14 | 15 | return ( 16 | value.slice( 17 | 0, 18 | length + (firstNewLine === -1 ? tail.length : firstNewLine), 19 | ) + '...' 20 | ); 21 | } 22 | 23 | export function markdownToPlainText(markdownString: string): string { 24 | // Remove markdown characters 25 | let plainText = markdownString.replace(/#+/g, ''); 26 | plainText = plainText.replace(/\*+/g, ''); 27 | plainText = plainText.replace(/_+/g, ''); 28 | plainText = plainText.replace(/`+/g, ''); 29 | plainText = plainText.replace(/~+/g, ''); 30 | 31 | // Remove links 32 | plainText = plainText.replace(/\[(.*?)\]\((.*?)\)/g, '$1'); 33 | 34 | return plainText; 35 | } 36 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/iconMap.ts: -------------------------------------------------------------------------------- 1 | import { classes } from '@tomic/react'; 2 | import { IconType } from 'react-icons'; 3 | import { 4 | FaAtom, 5 | FaBook, 6 | FaClock, 7 | FaComment, 8 | FaCube, 9 | FaCubes, 10 | FaFile, 11 | FaFileAlt, 12 | FaFileImport, 13 | FaFolder, 14 | FaHdd, 15 | FaListAlt, 16 | FaShareSquare, 17 | } from 'react-icons/fa'; 18 | 19 | const iconMap = new Map([ 20 | [classes.folder, FaFolder], 21 | [classes.bookmark, FaBook], 22 | [classes.chatRoom, FaComment], 23 | [classes.document, FaFileAlt], 24 | [classes.file, FaFile], 25 | [classes.drive, FaHdd], 26 | [classes.commit, FaClock], 27 | [classes.importer, FaFileImport], 28 | [classes.invite, FaShareSquare], 29 | [classes.collection, FaListAlt], 30 | [classes.class, FaCube], 31 | [classes.property, FaCubes], 32 | ]); 33 | 34 | export function getIconForClass( 35 | classSubject: string, 36 | fallback: IconType = FaAtom, 37 | ): IconType { 38 | return iconMap.get(classSubject) ?? fallback; 39 | } 40 | -------------------------------------------------------------------------------- /data-browser/src/views/Card/MessageCard.tsx: -------------------------------------------------------------------------------- 1 | import { useString, properties } from '@tomic/react'; 2 | import React from 'react'; 3 | import { CommitDetail } from '../../components/CommitDetail'; 4 | import Markdown from '../../components/datatypes/Markdown'; 5 | import { Detail, Details } from '../../components/Detail'; 6 | import { ResourceInline } from '../ResourceInline'; 7 | import { ResourcePageProps } from '../ResourcePage'; 8 | 9 | /** Card Message view that shows parent */ 10 | export function MessageCard({ resource }: ResourcePageProps) { 11 | const [description] = useString(resource, properties.description); 12 | const [parent] = useString(resource, properties.parent); 13 | const [lastCommit] = useString(resource, properties.commit.lastCommit); 14 | 15 | return ( 16 | <> 17 |
18 | 19 | Message in 20 | 21 | 22 |
23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /data-browser/src/components/ChildrenList.tsx: -------------------------------------------------------------------------------- 1 | import { useChildren } from '@tomic/react'; 2 | import React from 'react'; 3 | import { FaCaretDown, FaCaretRight } from 'react-icons/fa'; 4 | import { ResourceInline } from '../views/ResourceInline'; 5 | import { Button } from './Button'; 6 | import { Card, CardInsideFull, CardRow } from './Card'; 7 | 8 | export function Childrenlist({ resource }) { 9 | const [show, setShow] = React.useState(false); 10 | 11 | return ( 12 | <> 13 | 17 | {show && } 18 | 19 | ); 20 | } 21 | 22 | function ChildrenList({ resource }) { 23 | const children = useChildren(resource); 24 | 25 | return ( 26 | 27 | 28 | {children.map(s => ( 29 | 30 | 31 | 32 | ))} 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.35.0", 3 | "author": "Joep Meindertsma", 4 | "description": "Atomic Data React library", 5 | "dependencies": { 6 | "@tomic/lib": "workspace:*" 7 | }, 8 | "devDependencies": { 9 | "typescript": "^4.8" 10 | }, 11 | "peerDependencies": { 12 | "react": ">17.0.2" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "license": "MIT", 18 | "name": "@tomic/react", 19 | "main-dev": "src/index.ts", 20 | "main": "dist/src/index.js", 21 | "publishConfig": { 22 | "access": "public" 23 | }, 24 | "scripts": { 25 | "build": "tsc", 26 | "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", 27 | "lint-fix": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix", 28 | "prepublishOnly": "pnpm run lint-fix && pnpm run build", 29 | "start": "pnpm watch", 30 | "watch": "tsc --build --watch", 31 | "tsc": "tsc --build", 32 | "typecheck": "tsc --noEmit" 33 | }, 34 | "source": "src/index.ts", 35 | "type": "module", 36 | "types": "dist/src/index.d.ts", 37 | "gitHead": "2172c73d8df4e5f273e6386676abc91b6c5b2707" 38 | } 39 | -------------------------------------------------------------------------------- /data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useResource, useTitle } from '@tomic/react'; 3 | import { NewInstanceButtonProps } from './NewInstanceButtonProps'; 4 | import { Base } from './Base'; 5 | import { useDefaultNewInstanceHandler } from './useDefaultNewInstanceHandler'; 6 | 7 | /** Default handler for the new Instance button. DO NOT USE DIRECTLY. */ 8 | export function NewInstanceButtonDefault({ 9 | klass, 10 | subtle, 11 | icon, 12 | IconComponent, 13 | parent, 14 | children, 15 | label, 16 | className, 17 | }: NewInstanceButtonProps): JSX.Element { 18 | const classResource = useResource(klass); 19 | const [title] = useTitle(classResource); 20 | 21 | const onClick = useDefaultNewInstanceHandler(klass, parent); 22 | 23 | return ( 24 | 33 | {children} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/DisplayStyleButton.tsx: -------------------------------------------------------------------------------- 1 | import { classes } from '@tomic/react'; 2 | import React, { useMemo } from 'react'; 3 | import { FaList, FaTh } from 'react-icons/fa'; 4 | import { ButtonGroup } from '../../components/ButtonGroup'; 5 | 6 | export interface DisplayStyleButtonProps { 7 | displayStyle: string | undefined; 8 | onClick: (displayStyle: string) => void; 9 | } 10 | 11 | const { grid, list } = classes.displayStyles; 12 | 13 | export function DisplayStyleButton({ 14 | displayStyle, 15 | onClick, 16 | }: DisplayStyleButtonProps): JSX.Element { 17 | const options = useMemo( 18 | () => [ 19 | { 20 | icon: , 21 | label: 'List View', 22 | value: list, 23 | checked: displayStyle === list, 24 | }, 25 | { 26 | icon: , 27 | label: 'Grid View', 28 | value: grid, 29 | checked: displayStyle === grid, 30 | }, 31 | ], 32 | [displayStyle], 33 | ); 34 | 35 | return ( 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useQueryScope.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useNavigate } from 'react-router'; 3 | import { useQueryString } from '../helpers/navigation'; 4 | 5 | export interface QueryScopeHandler { 6 | scope: string | undefined; 7 | enableScope: () => void; 8 | clearScope: () => void; 9 | } 10 | 11 | export function useQueryScopeHandler(subject: string): QueryScopeHandler; 12 | export function useQueryScopeHandler(): Omit; 13 | export function useQueryScopeHandler(subject?: string): QueryScopeHandler { 14 | const [scope, setScope] = useQueryString('queryscope'); 15 | const navigate = useNavigate(); 16 | 17 | const enableScope = useCallback(() => { 18 | const params = new URLSearchParams({ 19 | queryscope: subject ?? '', 20 | }); 21 | 22 | navigate(`/app/search?${params.toString()}`, { replace: true }); 23 | }, [setScope, subject]); 24 | 25 | const clearScope = useCallback(() => { 26 | setScope(undefined); 27 | }, [setScope]); 28 | 29 | return { 30 | scope, 31 | enableScope, 32 | clearScope, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /data-browser/src/components/SideBar/SideBarMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { AtomicLink, AtomicLinkProps } from '../AtomicLink'; 4 | import { SideBarItem } from './SideBarItem'; 5 | 6 | export interface SideBarMenuItemProps extends AtomicLinkProps { 7 | label: string; 8 | helper?: string; 9 | icon?: React.ReactNode; 10 | disabled?: boolean; 11 | /** Is called when clicking on the item. Used for closing the menu. */ 12 | handleClickItem?: () => void; 13 | } 14 | 15 | export function SideBarMenuItem({ 16 | helper, 17 | label, 18 | icon, 19 | path, 20 | href, 21 | subject, 22 | handleClickItem, 23 | }: SideBarMenuItemProps) { 24 | return ( 25 | 26 | 27 | {icon && {icon}} 28 | {label} 29 | 30 | 31 | ); 32 | } 33 | 34 | const SideBarIcon = styled.span` 35 | display: flex; 36 | margin-right: 0.5rem; 37 | font-size: 1.5rem; 38 | `; 39 | -------------------------------------------------------------------------------- /data-browser/src/components/Shortcut.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { displayShortcut } from './HotKeyWrapper'; 4 | 5 | export interface ShortcutProps { 6 | shortcut: string; 7 | className?: string; 8 | } 9 | 10 | export function Shortcut({ shortcut, className }: ShortcutProps): JSX.Element { 11 | const parts = displayShortcut(shortcut).split('+'); 12 | 13 | return ( 14 | 15 | {parts.map((part, i) => ( 16 | 17 | {part} {i < parts.length - 1 && '+ '} 18 | 19 | ))} 20 | 21 | ); 22 | } 23 | 24 | const Wrapper = styled.span` 25 | font-size: 10px; 26 | `; 27 | 28 | const KBD = styled.kbd` 29 | display: inline-block; 30 | border: ${p => p.theme.colors.bg2} solid 1px; 31 | background-color: ${p => p.theme.colors.bg1}; 32 | text-transform: capitalize; 33 | border-radius: 5px; 34 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI Adjusted', 35 | 'Segoe UI', 'Liberation Sans', sans-serif; 36 | padding: 0.3em; 37 | `; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joep Meindertsma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /data-browser/src/views/AgentPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useArray, useTitle, properties } from '@tomic/react'; 3 | 4 | import { ContainerNarrow } from '../components/Containers'; 5 | import { CardRow } from '../components/Card'; 6 | import { ResourceInline } from './ResourceInline'; 7 | import { ValueForm } from '../components/forms/ValueForm'; 8 | import { ResourcePageProps } from './ResourcePage'; 9 | 10 | /** A View for Drives, which function similar to a homepage or dashboard. */ 11 | function AgentPage({ resource }: ResourcePageProps): JSX.Element { 12 | const [title] = useTitle(resource); 13 | const [children] = useArray(resource, properties.children); 14 | 15 | return ( 16 | 17 | 18 |

{title}

19 | {children.map(child => { 20 | return ( 21 | 22 | 23 | 24 | ); 25 | })} 26 |
27 | ); 28 | } 29 | 30 | export default AgentPage; 31 | -------------------------------------------------------------------------------- /data-browser/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export const Spinner = () => ( 5 | 6 | 14 | 15 | ); 16 | 17 | const StyledSpinner = styled.svg` 18 | animation: rotate 2s linear infinite; 19 | width: 50px; 20 | height: 50px; 21 | max-width: 100%; 22 | max-height: 100%; 23 | 24 | & .path { 25 | stroke: ${props => props.theme.colors.main}; 26 | stroke-linecap: round; 27 | animation: dash 1.5s ease-in-out infinite; 28 | } 29 | 30 | @keyframes rotate { 31 | 100% { 32 | transform: rotate(360deg); 33 | } 34 | } 35 | @keyframes dash { 36 | 0% { 37 | stroke-dasharray: 1, 150; 38 | stroke-dashoffset: 0; 39 | } 40 | 50% { 41 | stroke-dasharray: 90, 150; 42 | stroke-dashoffset: -35; 43 | } 44 | 100% { 45 | stroke-dasharray: 90, 150; 46 | stroke-dashoffset: -124; 47 | } 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /data-browser/src/components/NewCard.tsx: -------------------------------------------------------------------------------- 1 | import { FaPlus } from 'react-icons/fa'; 2 | import styled from 'styled-components'; 3 | import { GridCard } from '../views/FolderPage/GridItem/components'; 4 | import React from 'react'; 5 | 6 | export interface NewCardProps { 7 | onClick: (e: React.MouseEvent) => void; 8 | } 9 | 10 | export function NewCard({ onClick }: NewCardProps) { 11 | return ( 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | const Thing = styled(GridCard)` 19 | background-color: ${p => p.theme.colors.bg1}; 20 | border: 1px solid ${p => p.theme.colors.bg2}; 21 | cursor: pointer; 22 | display: grid; 23 | place-items: center; 24 | height: 100%; 25 | width: 100%; 26 | font-size: 3rem; 27 | color: ${p => p.theme.colors.textLight}; 28 | transition: color 0.1s ease-in-out, font-size 0.1s ease-out, 29 | border-color 0.1s ease-in-out; 30 | &:hover, 31 | &:focus { 32 | color: ${p => p.theme.colors.main}; 33 | font-size: 3.8rem; 34 | border-color: ${p => p.theme.colors.main}; 35 | } 36 | 37 | :active { 38 | font-size: 3rem; 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /data-browser/src/views/MessagePage.tsx: -------------------------------------------------------------------------------- 1 | import { useString, properties } from '@tomic/react'; 2 | import React from 'react'; 3 | import { CommitDetail } from '../components/CommitDetail'; 4 | import { ContainerNarrow } from '../components/Containers'; 5 | import Markdown from '../components/datatypes/Markdown'; 6 | import { Details } from '../components/Detail'; 7 | import { ResourceInline } from './ResourceInline'; 8 | import { ResourcePageProps } from './ResourcePage'; 9 | 10 | /** Full page Message view that should (in the future) render replies */ 11 | export function MessagePage({ resource }: ResourcePageProps) { 12 | const [description] = useString(resource, properties.description); 13 | const [parent] = useString(resource, properties.parent); 14 | const [lastCommit] = useString(resource, properties.commit.lastCommit); 15 | 16 | return ( 17 | 18 |

19 | Message in 20 |

21 |
22 | 23 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/InputString.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useString } from '@tomic/react'; 3 | import { InputProps } from './ResourceField'; 4 | import { ErrMessage, InputStyled, InputWrapper } from './InputStyles'; 5 | 6 | export default function InputString({ 7 | resource, 8 | property, 9 | ...props 10 | }: InputProps): JSX.Element { 11 | const [err, setErr] = useState(undefined); 12 | const [value, setValue] = useString(resource, property.subject, { 13 | handleValidationError: setErr, 14 | }); 15 | 16 | function handleUpdate(e: React.ChangeEvent): void { 17 | const newval = e.target.value; 18 | // I pass the error setter for validation purposes 19 | setValue(newval); 20 | } 21 | 22 | return ( 23 | <> 24 | 25 | 30 | 31 | {value !== '' && err && {err.message}} 32 | {value === '' && Required} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/InputResource.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { InputProps } from './ResourceField'; 3 | import { noNestedSupport, useSubject } from '@tomic/react'; 4 | import { ResourceSelector } from './ResourceSelector'; 5 | import { ErrorLook } from '../ErrorLook'; 6 | 7 | /** Input field for a single Resource. Renders a dropdown select menu. */ 8 | export function InputResource({ 9 | resource, 10 | property, 11 | ...props 12 | }: InputProps): JSX.Element { 13 | const [error, setError] = useState(undefined); 14 | const [subject, setSubject] = useSubject(resource, property.subject, { 15 | handleValidationError: setError, 16 | }); 17 | 18 | if (subject === noNestedSupport) { 19 | return ( 20 | 21 | Sorry, there is no support for editing nested resources yet 22 | 23 | ); 24 | } 25 | 26 | return ( 27 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /data-browser/src/components/NewInstanceButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { classes } from '@tomic/react'; 2 | import React from 'react'; 3 | import { NewBookmarkButton } from './NewBookmarkButton'; 4 | import { NewInstanceButtonProps } from './NewInstanceButtonProps'; 5 | import { NewInstanceButtonDefault } from './NewInstanceButtonDefault'; 6 | import { useSettings } from '../../helpers/AppSettings'; 7 | 8 | type InstanceButton = (props: NewInstanceButtonProps) => JSX.Element; 9 | 10 | /** If your New Instance button requires custom logic, such as a custom dialog */ 11 | const classMap = new Map([ 12 | [classes.bookmark, NewBookmarkButton], 13 | ]); 14 | 15 | /** A button for creating a new instance of some thing */ 16 | export default function NewInstanceButton( 17 | props: NewInstanceButtonProps, 18 | ): JSX.Element { 19 | const { klass, parent } = props; 20 | const { drive } = useSettings(); 21 | 22 | const Comp = classMap.get(klass) ?? NewInstanceButtonDefault; 23 | 24 | return ; 25 | } 26 | 27 | export { useDefaultNewInstanceHandler } from './useDefaultNewInstanceHandler'; 28 | export { useCreateAndNavigate } from './useCreateAndNavigate'; 29 | -------------------------------------------------------------------------------- /data-browser/src/components/NavBarSpacer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useSettings } from '../helpers/AppSettings'; 4 | 5 | const NAVBAR_HEIGHT = '2rem'; 6 | const NAVBAR_CALC_PART = ` + ${NAVBAR_HEIGHT}`; 7 | 8 | export interface NavBarSpacerProps { 9 | position: 'top' | 'bottom'; 10 | baseMargin?: string; 11 | } 12 | 13 | const size = (base = '0rem', withNav: boolean) => 14 | `calc(${base}${withNav ? NAVBAR_CALC_PART : ''})`; 15 | 16 | /** Makes room for the navbar when it is present at the given position. Animates its height. */ 17 | export function NavBarSpacer({ 18 | position, 19 | baseMargin, 20 | }: NavBarSpacerProps): JSX.Element { 21 | const { navbarFloating, navbarTop } = useSettings(); 22 | 23 | const getSize = () => { 24 | if (position === 'top') { 25 | return size(baseMargin, navbarTop); 26 | } 27 | 28 | return size(baseMargin, !navbarFloating && !navbarTop); 29 | }; 30 | 31 | return ; 32 | } 33 | 34 | interface SpacingProps { 35 | size: string; 36 | } 37 | 38 | const Spacing = styled.div` 39 | height: ${p => p.size}; 40 | transition: height 0.2s ease-out; 41 | `; 42 | -------------------------------------------------------------------------------- /react/src/useServerURL.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@tomic/lib'; 2 | import { useCallback } from 'react'; 3 | import { useLocalStorage, useStore } from './index.js'; 4 | 5 | /** 6 | * A hook for using and adjusting the Server URL. Also saves to localStorage. If 7 | * the URL is wrong, an error is thrown using the store's handler 8 | */ 9 | export const useServerURL = (): [string, (serverUrl: string) => void] => { 10 | // Localstorage for cross-session persistence of JSON object 11 | const store = useStore(); 12 | const [serverUrl, setServerUrl] = useLocalStorage( 13 | 'serverUrl', 14 | store.getServerUrl(), 15 | ); 16 | 17 | const set = useCallback( 18 | (value: string) => { 19 | if (!value) { 20 | return; 21 | } 22 | 23 | let newValue = 'https://atomicdata.dev'; 24 | 25 | if (Client.isValidSubject(value)) { 26 | newValue = value; 27 | } else { 28 | store.notifyError( 29 | new Error(`Invalid base URL: ${value}, defaulting to atomicdata.dev`), 30 | ); 31 | } 32 | 33 | setServerUrl(newValue); 34 | store.setServerUrl(newValue); 35 | }, 36 | [store], 37 | ); 38 | 39 | return [serverUrl, set]; 40 | }; 41 | -------------------------------------------------------------------------------- /data-browser/src/helpers/loggingHandlers.tsx: -------------------------------------------------------------------------------- 1 | import Bugsnag from '@bugsnag/js'; 2 | import BugsnagPluginReact, { 3 | BugsnagErrorBoundary, 4 | } from '@bugsnag/plugin-react'; 5 | import React from 'react'; 6 | import { isDev } from '../config'; 7 | 8 | export function handleError(e: Error): void { 9 | // We already toast in the `errorHandler` 10 | // toast.error(e.message); 11 | console.error(e); 12 | 13 | if (!isDev) { 14 | Bugsnag.notify(e); 15 | } 16 | } 17 | 18 | export function handleWarning(e: Error | string): void { 19 | // eslint-disable-next-line no-console 20 | console.warn(e); 21 | // TODO maybe handle these in Bugsnag? 22 | } 23 | 24 | export function handleInfo(e: Error): void { 25 | // eslint-disable-next-line no-console 26 | console.info(e); 27 | 28 | if (!isDev) { 29 | Bugsnag.notify(e); 30 | } 31 | } 32 | 33 | export function initBugsnag(apiKey: string): BugsnagErrorBoundary { 34 | Bugsnag.start({ 35 | apiKey, 36 | plugins: [new BugsnagPluginReact()], 37 | releaseStage: isDev() ? 'development' : 'production', 38 | enabledReleaseStages: ['production'], 39 | autoDetectErrors: !isDev(), 40 | }); 41 | 42 | return Bugsnag.getPlugin('react')!.createErrorBoundary(React); 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "ES2022", 5 | "lib": [ 6 | "ES6", 7 | "ES7", 8 | "ESNext" 9 | ], 10 | // Enforces `.js` relative imports, which is needed because tsc doesn't update filenames 11 | "moduleResolution": "nodeNext", 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "jsx": "preserve", 15 | "strictFunctionTypes": true, 16 | "sourceMap": true, 17 | "declaration": true, 18 | // Enables following definitions to source files instead of d.ts files 19 | "declarationMap": true, 20 | // used in monorepo ts projects to set precedence in compiling tsc things https://dev.to/t7yang/typescript-yarn-workspace-monorepo-1pao 21 | "composite": true, 22 | "strictNullChecks": true, 23 | // Todo: enable this 24 | // "noImplicitAny": true 25 | "downlevelIteration": true, 26 | // Prevent typecheck to fail if some library is doing something wrong 27 | "skipLibCheck": true 28 | }, 29 | "exclude": [ 30 | "node_modules", 31 | "**/node_modules/*" 32 | ], 33 | "references": [ 34 | { 35 | "path": "lib" 36 | }, 37 | { 38 | "path": "react" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /data-browser/src/helpers/transitionName.ts: -------------------------------------------------------------------------------- 1 | const hashStringWithCYRB53 = (str, seed = 0) => { 2 | let h1 = 0xdeadbeef ^ seed, 3 | h2 = 0x41c6ce57 ^ seed; 4 | 5 | for (let i = 0, ch; i < str.length; i++) { 6 | ch = str.charCodeAt(i); 7 | h1 = Math.imul(h1 ^ ch, 2654435761); 8 | h2 = Math.imul(h2 ^ ch, 1597334677); 9 | } 10 | 11 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); 12 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); 13 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); 14 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); 15 | 16 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 17 | }; 18 | 19 | export function getTransitionName(tag: string, subject: string | undefined) { 20 | if (!subject) { 21 | throw new Error('Subject is required for transition name'); 22 | } 23 | 24 | // URL's are not allowed in view-transition-name so we hash the subject. 25 | return `${tag}-${hashStringWithCYRB53(subject ?? '')}`; 26 | } 27 | 28 | export function transitionName(tag: string, subject: string | undefined) { 29 | let name: string; 30 | 31 | try { 32 | name = getTransitionName(tag, subject); 33 | } catch (e) { 34 | return 'view-transition-name: none'; 35 | } 36 | 37 | return `view-transition-name: ${name}`; 38 | } 39 | -------------------------------------------------------------------------------- /data-browser/src/components/IconButton/IconButton.story.mdx: -------------------------------------------------------------------------------- 1 | import { IconButton, IconButtonVariant } from './index'; 2 | import { Row } from '../Row'; 3 | import { FaPoo } from 'react-icons/fa'; 4 | 5 | # IconButton 6 | 7 | ## Demo 8 | 9 | ### Default 10 | 11 | 12 | 13 | 14 | 15 | ### Color 16 | 17 | 18 | 19 | 20 | 21 | ### Size 22 | 23 | 24 | 25 | 26 | 27 | ### HTML Button attributes 28 | 29 | IconButton accepts all html button attributes 30 | 31 | 32 | 33 | 34 | 35 | ### Variants 36 | 37 | IconButton has the following variants: 38 | 39 | ```ts 40 | IconButtonVariant.Simple; 41 | IconButtonVariant.Outline; 42 | IconButtonVariant.Fill; 43 | ``` 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /data-browser/src/routes/History/VersionButton.tsx: -------------------------------------------------------------------------------- 1 | import { Version } from '@tomic/react'; 2 | import React from 'react'; 3 | import { DateTime } from '../../components/datatypes/DateTime'; 4 | import styled from 'styled-components'; 5 | import { ButtonClean } from '../../components/Button'; 6 | 7 | export interface VersionButtonProps { 8 | version: Version; 9 | selected: boolean; 10 | onClick: () => void; 11 | } 12 | 13 | export function VersionButton({ 14 | version, 15 | selected, 16 | onClick, 17 | }: VersionButtonProps) { 18 | return ( 19 | 26 | 27 | 28 | ); 29 | } 30 | 31 | const VersionRow = styled(ButtonClean)<{ selected: boolean }>` 32 | padding: 1rem; 33 | background-color: ${p => (p.selected ? p.theme.colors.main : 'transparent')}; 34 | color: ${p => (p.selected ? 'white' : p.theme.colors.text)}; 35 | border-radius: ${p => p.theme.radius}; 36 | 37 | :hover, 38 | :focus-visible { 39 | background: ${p => (p.selected ? p.theme.colors.main : p.theme.colors.bg1)}; 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useNavigateWithTransition.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { flushSync } from 'react-dom'; 3 | import { useNavigate } from 'react-router'; 4 | import { useSettings } from '../helpers/AppSettings'; 5 | const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); 6 | 7 | /** 8 | * A wrapper around react-router's navigate function that will trigger css view transitions if enabled. 9 | */ 10 | export function useNavigateWithTransition() { 11 | const navigate = useNavigate(); 12 | const { viewTransitionsEnabled } = useSettings(); 13 | 14 | const navigateWithTransition = useCallback( 15 | (to: string | number) => { 16 | // @ts-ignore 17 | if (!viewTransitionsEnabled || !document.startViewTransition) { 18 | //@ts-ignore 19 | navigate(to); 20 | 21 | return; 22 | } 23 | 24 | // @ts-ignore 25 | document.startViewTransition( 26 | async () => 27 | new Promise(resolve => { 28 | flushSync(() => { 29 | // @ts-ignore 30 | navigate(to); 31 | wait(1).then(() => { 32 | resolve(); 33 | }); 34 | }); 35 | }), 36 | ); 37 | }, 38 | [navigate], 39 | ); 40 | 41 | return navigateWithTransition; 42 | } 43 | -------------------------------------------------------------------------------- /data-browser/src/components/CommitDetail.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { properties, useDate, useResource, useString } from '@tomic/react'; 3 | import { ResourceInline } from '../views/ResourceInline'; 4 | import { Detail } from './Detail'; 5 | import { DateTime } from './datatypes/DateTime'; 6 | import { AtomicLink } from './AtomicLink'; 7 | 8 | type Props = { 9 | commitSubject?: string; 10 | }; 11 | 12 | /** Shows the latest editor and edit date */ 13 | export function CommitDetail({ commitSubject }: Props): JSX.Element | null { 14 | const resource = useResource(commitSubject); 15 | const [signer] = useString(resource, properties.commit.signer); 16 | const [previousCommit] = useString( 17 | resource, 18 | properties.commit.previousCommit, 19 | ); 20 | const createdAt = useDate(resource, properties.commit.createdAt); 21 | 22 | if (!commitSubject) { 23 | return null; 24 | } 25 | 26 | if (!commitSubject || !resource.isReady) { 27 | return loading...; 28 | } 29 | 30 | return ( 31 | 32 | {signer && } 33 | {'-'} 34 | 35 | {previousCommit ? 'edited ' : ''} 36 | {createdAt && } 37 | {' '} 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.35.1", 3 | "author": "Joep Meindertsma", 4 | "dependencies": { 5 | "@noble/ed25519": "1.6.0", 6 | "@noble/hashes": "^0.5.7", 7 | "base64-arraybuffer": "^1.0.2", 8 | "cross-fetch": "^3.1.4", 9 | "fast-json-stable-stringify": "^2.1.0" 10 | }, 11 | "description": "", 12 | "devDependencies": { 13 | "@types/fast-json-stable-stringify": "^2.1.0", 14 | "chai": "^4.3.4", 15 | "typescript": "^4.8", 16 | "whatwg-fetch": "^3.6.2" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "license": "MIT", 22 | "main-dev": "src/index.ts", 23 | "main": "dist/src/index.js", 24 | "name": "@tomic/lib", 25 | "publishConfig": { 26 | "access": "public" 27 | }, 28 | "scripts": { 29 | "build": "tsc", 30 | "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", 31 | "lint-fix": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix", 32 | "prepublishOnly": "pnpm run build && pnpm run lint-fix", 33 | "watch": "tsc --build --watch", 34 | "start": "pnpm watch", 35 | "test": "NODE_OPTIONS='--experimental-vm-modules' ../node_modules/jest/bin/jest.js", 36 | "tsc": "tsc --build", 37 | "typecheck": "tsc --noEmit" 38 | }, 39 | "source": "src/index.ts", 40 | "type": "module", 41 | "types": "dist/src/index.d.ts", 42 | "gitHead": "2172c73d8df4e5f273e6386676abc91b6c5b2707" 43 | } 44 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/InputNumber.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useNumber } from '@tomic/react'; 3 | import { InputProps } from './ResourceField'; 4 | import { ErrMessage, InputStyled, InputWrapper } from './InputStyles'; 5 | 6 | export default function InputNumber({ 7 | resource, 8 | property, 9 | ...props 10 | }: InputProps): JSX.Element { 11 | const [err, setErr] = useState(undefined); 12 | const [value, setValue] = useNumber(resource, property.subject, { 13 | handleValidationError: setErr, 14 | }); 15 | 16 | function handleUpdate(e) { 17 | if (e.target.value === '') { 18 | setValue(undefined); 19 | 20 | return; 21 | } 22 | 23 | const newval = +e.target.value; 24 | // I pass the error setter for validation purposes 25 | setValue(newval); 26 | } 27 | 28 | return ( 29 | <> 30 | 31 | 38 | 39 | {value !== undefined && err && {err.message}} 40 | {value === undefined && Required} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /data-browser/src/views/CrashPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Resource } from '@tomic/react'; 3 | 4 | import { ContainerWide } from '../components/Containers'; 5 | import { ErrorBlock } from '../components/ErrorLook'; 6 | import { Button } from '../components/Button'; 7 | import { Column, Row } from '../components/Row'; 8 | 9 | type ErrorPageProps = { 10 | resource?: Resource; 11 | children?: React.ReactNode; 12 | error: Error; 13 | info: React.ErrorInfo; 14 | clearError: () => void; 15 | }; 16 | 17 | /** If the entire app crashes, show this page */ 18 | function CrashPage({ 19 | resource, 20 | children, 21 | error, 22 | clearError, 23 | }: ErrorPageProps): JSX.Element { 24 | return ( 25 | 26 | 27 | {children ? children : } 28 | 29 | {clearError && } 30 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | export default CrashPage; 47 | -------------------------------------------------------------------------------- /data-browser/src/helpers/useHover.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef, useState } from 'react'; 2 | 3 | // hook returns tuple(array) with type [any, boolean] 4 | // T - could be any type of HTML element like: HTMLDivElement, HTMLParagraphElement and etc. 5 | export function useHover( 6 | disabled: boolean, 7 | ): [RefObject, boolean] { 8 | const [value, setValue] = useState(false); 9 | 10 | const ref = useRef(null); 11 | 12 | useEffect(() => { 13 | const handleMouseOver = (): void => setValue(true); 14 | const handleMouseOut = (): void => setValue(false); 15 | 16 | // eslint-disable-next-line 17 | const node = ref.current; 18 | 19 | // This could be expensive, and triggers re-renders for some reasons. 20 | // That's why it's disabled as much as possible. 21 | if (!disabled && node) { 22 | node.addEventListener('mouseover', handleMouseOver); 23 | node.addEventListener('mouseout', handleMouseOut); 24 | 25 | return () => { 26 | node.removeEventListener('mouseover', handleMouseOver); 27 | node.removeEventListener('mouseout', handleMouseOut); 28 | }; 29 | } 30 | }, [disabled]); 31 | 32 | // don't hover on touch screen devices 33 | if (window.matchMedia('(pointer: coarse)').matches) { 34 | return [ref, false]; 35 | } 36 | 37 | return [ref, value]; 38 | } 39 | -------------------------------------------------------------------------------- /react/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * # @tomic/react Documentation 3 | * 4 | * Render, fetch, edit and delete [Atomic Data](https://atomicdata.dev). 5 | * Re-exports all of [`@tomic/lib`](https://www.npmjs.com/package/@tomic/lib). 6 | * 7 | * [github repository](https://github.com/atomicdata-dev/atomic-data-browser) 8 | * 9 | * ## How to use 10 | * 11 | * - Add [`@tomic/react`](https://www.npmjs.com/package/@tomic/react) and to your 12 | * `package.json` `dependencies`. 13 | * - Start by initializing a {@link Store}`const store = new Store()` form `@tomic/lib`. 14 | * - Wrap your React application in a `` component. 15 | * - Add {@link useResource} and {@link useValue} hooks (e.g. {@link useArray}) to 16 | * your React components. 17 | * - Add User and session management using the {@link useCurrentAgent} hook 18 | * 19 | * For example usage, see [this CodeSandbox 20 | * template](https://codesandbox.io/s/atomic-data-react-template-4y9qu?file=/src/MyResource.tsx:304-388). 21 | * 22 | * @module 23 | */ 24 | 25 | export * from './hooks.js'; 26 | export * from './useServerURL.js'; 27 | export * from './useCurrentAgent.js'; 28 | export * from './useChildren.js'; 29 | export * from './useDebounce.js'; 30 | export * from './useLocalStorage.js'; 31 | export * from './useMarkdown.js'; 32 | export * from './useServerSearch.js'; 33 | export * from '@tomic/lib'; 34 | -------------------------------------------------------------------------------- /data-browser/src/views/ResourceLine.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { urls, useString, useResource, useTitle } from '@tomic/react'; 3 | import { ResourceInline } from './ResourceInline'; 4 | import { ErrorLook } from '../components/ErrorLook'; 5 | 6 | type Props = { 7 | subject: string; 8 | clickable?: boolean; 9 | }; 10 | 11 | /** Renders a Resource in a small line item. Not a link. Useful in dropdown. */ 12 | function ResourceLine({ subject, clickable }: Props): JSX.Element { 13 | const resource = useResource(subject); 14 | const [title] = useTitle(resource); 15 | let [description] = useString(resource, urls.properties.description); 16 | 17 | if (resource.loading) { 18 | return Loading...; 19 | } 20 | 21 | if (resource.error) { 22 | return ( 23 | Error: {resource.error.message} 24 | ); 25 | } 26 | 27 | const TRUNCATE_LENGTH = 40; 28 | 29 | if (description && description.length >= TRUNCATE_LENGTH) { 30 | description = description.slice(0, TRUNCATE_LENGTH) + '...'; 31 | } 32 | 33 | return ( 34 | 35 | {clickable ? ( 36 | 37 | ) : ( 38 | {title} 39 | )} 40 | {description ? ` - ${description}` : null} 41 | 42 | ); 43 | } 44 | 45 | export default ResourceLine; 46 | -------------------------------------------------------------------------------- /data-browser/src/views/File/TextPreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import Markdown from '../../components/datatypes/Markdown'; 4 | 5 | interface TextPreviewProps { 6 | downloadUrl: string; 7 | mimeType: string; 8 | className?: string; 9 | } 10 | 11 | const fetchFile = async ( 12 | url: string, 13 | signal: AbortSignal, 14 | mimeType: string, 15 | ) => { 16 | const res = await fetch(url, { 17 | credentials: 'include', 18 | headers: { 19 | Accept: mimeType, 20 | }, 21 | signal, 22 | }); 23 | 24 | return res.text(); 25 | }; 26 | 27 | export function TextPreview({ 28 | downloadUrl, 29 | mimeType, 30 | className, 31 | }: TextPreviewProps): JSX.Element { 32 | const [data, setData] = useState(''); 33 | 34 | useEffect(() => { 35 | const abortController = new AbortController(); 36 | 37 | fetchFile(downloadUrl, abortController.signal, mimeType).then(res => 38 | setData(res), 39 | ); 40 | 41 | return () => abortController.abort(); 42 | }, [downloadUrl]); 43 | 44 | if (mimeType === 'text/markdown') { 45 | return ( 46 |
47 | 48 |
49 | ); 50 | } 51 | 52 | return {data}; 53 | } 54 | 55 | const Wrapper = styled.pre` 56 | white-space: pre-wrap; 57 | `; 58 | -------------------------------------------------------------------------------- /data-browser/src/components/Dialog/useDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react'; 2 | import { InternalDialogProps } from './index'; 3 | 4 | export type UseDialogReturnType = [ 5 | /** Props meant to pass to a {@link Dialog} component */ 6 | dialogProps: InternalDialogProps, 7 | /** Function to show the dialog */ 8 | show: () => void, 9 | /** Function to close the dialog */ 10 | close: () => void, 11 | /** Boolean indicating wether the dialog is currently open */ 12 | isOpen: boolean, 13 | ]; 14 | 15 | /** Sets up state, and functions to use with a {@link Dialog} */ 16 | export const useDialog = (): UseDialogReturnType => { 17 | const [showDialog, setShowDialog] = useState(false); 18 | const [visible, setVisible] = useState(false); 19 | 20 | const show = useCallback(() => { 21 | setShowDialog(true); 22 | setVisible(true); 23 | }, []); 24 | 25 | const close = useCallback(() => { 26 | setShowDialog(false); 27 | }, []); 28 | 29 | const handleClosed = useCallback(() => { 30 | setVisible(false); 31 | }, []); 32 | 33 | /** Props that should be passed to a {@link Dialog} component. */ 34 | const dialogProps = useMemo( 35 | () => ({ 36 | show: showDialog, 37 | onClose: close, 38 | onClosed: handleClosed, 39 | }), 40 | [showDialog, close, handleClosed], 41 | ); 42 | 43 | return [dialogProps, show, close, visible]; 44 | }; 45 | -------------------------------------------------------------------------------- /lib/src/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Resource, Store, urls } from './index.js'; 2 | /** Endpoints are Resources that can respond to query parameters or POST bodies */ 3 | 4 | type ImportOpts = { 5 | /** Where the resources will be imported to */ 6 | parent: string; 7 | /** Danger: Replaces Resources with matching subjects, even if they are not Children of the specified Parent. */ 8 | overwriteOutside?: boolean; 9 | }; 10 | 11 | const addParams = (urlBase: string, params: Record) => { 12 | const parsed = new URL(urlBase); 13 | 14 | for (const [key, val] of Object.entries(params)) { 15 | parsed.searchParams.set(key, val); 16 | } 17 | 18 | return parsed.toString(); 19 | }; 20 | 21 | function resourceToErr(resource: Resource) { 22 | if (resource.error) { 23 | throw resource.error; 24 | } else { 25 | return Resource; 26 | } 27 | } 28 | 29 | /** 30 | * POSTs a JSON-AD string (containing either an array of Resources or one Resource object) to the Server. 31 | * See https://docs.atomicdata.dev/create-json-ad.html 32 | */ 33 | export async function importJsonAdString( 34 | store: Store, 35 | jsonAdString: string, 36 | opts: ImportOpts, 37 | ) { 38 | const url = addParams(store.getServerUrl() + urls.endpoints.import, { 39 | parent: opts.parent, 40 | 'overwrite-outside': opts.overwriteOutside ? 'true' : 'false', 41 | }); 42 | 43 | return resourceToErr(await store.postToServer(url, jsonAdString)); 44 | } 45 | -------------------------------------------------------------------------------- /data-browser/src/views/File/DownloadButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaDownload } from 'react-icons/fa'; 3 | import styled from 'styled-components'; 4 | import { Button } from '../../components/Button'; 5 | import { IconButton } from '../../components/IconButton/IconButton'; 6 | import { Row } from '../../components/Row'; 7 | import { displayFileSize } from './displayFileSize'; 8 | 9 | interface DownloadButtonProps { 10 | downloadFile: () => void; 11 | fileSize?: number; 12 | } 13 | 14 | export function DownloadIconButton({ 15 | downloadFile, 16 | fileSize, 17 | }: DownloadButtonProps): JSX.Element { 18 | return ( 19 | 23 | 24 | 25 | ); 26 | } 27 | 28 | const DownloadIcon = styled(FaDownload)` 29 | color: ${({ theme }) => theme.colors.main}; 30 | `; 31 | 32 | export function DownloadButton({ 33 | downloadFile, 34 | fileSize, 35 | }: DownloadButtonProps): JSX.Element { 36 | return ( 37 | 41 | 42 | 43 | Download 44 | 45 | 46 | ); 47 | } 48 | 49 | const StyledButton = styled(Button)` 50 | view-transition-name: download-button; 51 | `; 52 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | permissions: write-all 3 | on: 4 | workflow_dispatch: 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - name: Install Node.js 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | 16 | - uses: pnpm/action-setup@v2.0.1 17 | name: Install pnpm 18 | id: pnpm-install 19 | with: 20 | version: 7 21 | run_install: false 22 | 23 | - name: Get pnpm store directory 24 | id: pnpm-cache 25 | run: | 26 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 27 | 28 | - uses: actions/cache@v3 29 | name: Setup pnpm cache 30 | with: 31 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 32 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 33 | restore-keys: | 34 | ${{ runner.os }}-pnpm-store- 35 | 36 | - run: pnpm install 37 | - run: pnpm lint-fix 38 | - run: pnpm build 39 | - run: pnpm typedoc 40 | - name: Deploy production 🚀 41 | if: ${{github.ref == 'refs/heads/main'}} 42 | uses: JamesIves/github-pages-deploy-action@4.0.0 43 | with: 44 | branch: gh-pages # The branch the action should deploy to. 45 | folder: data-browser/publish # The folder the action should deploy. 46 | -------------------------------------------------------------------------------- /lib/src/EventManager.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { EventManager } from './EventManager.js'; 3 | enum Events { 4 | Click = 'click', 5 | LotteryWon = 'lotterywon', 6 | } 7 | 8 | type EventHandlers = { 9 | [Events.Click]: (message: string) => void; 10 | [Events.LotteryWon]: (lotteryNumber: number[]) => void; 11 | }; 12 | 13 | describe('EventManager', () => { 14 | it('registers events', () => { 15 | const eventManager = new EventManager(); 16 | 17 | const cb = jest.fn(); 18 | eventManager.register(Events.Click, cb); 19 | 20 | eventManager.emit(Events.Click, 'Hello'); 21 | 22 | expect(cb).toHaveBeenCalledWith('Hello'); 23 | }); 24 | 25 | it('calls the correct handlers', () => { 26 | const eventManager = new EventManager(); 27 | 28 | const cb = jest.fn(); 29 | eventManager.register(Events.Click, cb); 30 | 31 | eventManager.emit(Events.Click, 'Hello'); 32 | eventManager.emit(Events.LotteryWon, [1, 2, 3]); 33 | 34 | expect(cb).toHaveBeenCalledTimes(1); 35 | }); 36 | 37 | it('unsubscribes', () => { 38 | const eventManager = new EventManager(); 39 | 40 | const cb = jest.fn(); 41 | const unsub = eventManager.register(Events.Click, cb); 42 | 43 | eventManager.emit(Events.Click, 'Hello'); 44 | unsub(); 45 | eventManager.emit(Events.Click, 'Bye'); 46 | 47 | expect(cb).toHaveBeenCalledTimes(1); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/NewForm/NewFormPage.tsx: -------------------------------------------------------------------------------- 1 | import { useResource } from '@tomic/react'; 2 | import { useQueryString } from '../../../helpers/navigation'; 3 | import { ResourceForm } from '../ResourceForm'; 4 | import { NewFormTitle } from './NewFormTitle'; 5 | import { SubjectField } from './SubjectField'; 6 | import { useNewForm } from './useNewForm'; 7 | import React from 'react'; 8 | 9 | export interface NewFormProps { 10 | classSubject: string; 11 | } 12 | 13 | /** Fullpage Form for instantiating a new Resource from some Class */ 14 | export const NewFormFullPage = ({ 15 | classSubject, 16 | }: NewFormProps): JSX.Element => { 17 | const klass = useResource(classSubject); 18 | const [subject, setSubject] = useQueryString('newSubject'); 19 | const [parentSubject] = useQueryString('parent'); 20 | 21 | const { subjectErr, subjectValue, setSubjectValue, resource } = useNewForm({ 22 | klass, 23 | setSubject, 24 | initialSubject: subject, 25 | parent: parentSubject, 26 | }); 27 | 28 | return ( 29 | <> 30 | 31 | 36 | {/* Key is required for re-rendering when subject changes */} 37 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /lib/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * # @tomic/lib Documentation 3 | * 4 | * Core typescript library for handling JSON-AD parsing, storing Atomic Data, 5 | * signing Commits, and more. 6 | * 7 | * [github repository](https://github.com/atomicdata-dev/atomic-data-browser) 8 | * 9 | * ## Features 10 | * 11 | * - Fetching Atomic Data 12 | * - Parsing JSON-AD 13 | * - Storing Atomic Data 14 | * - Data Validation 15 | * - Creating and signing {@link Commit} 16 | * 17 | * ## Usage 18 | * 19 | * You'll probably want to start by initializing a {@link Store}. Use methods 20 | * from the Store to load Resources. Use the {@link Resource} class to access, 21 | * edit and validate the data in a Resource. Use `Resource.save()` to save and 22 | * send edits to resources as Commits, or use the {@link Commit} class if you 23 | * need more control. 24 | * 25 | * ## Usage with react 26 | * 27 | * See `@tomic/react`, which provides various hooks for easy data usage. 28 | * 29 | * @module 30 | */ 31 | 32 | export * from './agent.js'; 33 | export * from './authentication.js'; 34 | export * from './class.js'; 35 | export * from './client.js'; 36 | export * from './commit.js'; 37 | export * from './error.js'; 38 | export * from './endpoints.js'; 39 | export * from './datatypes.js'; 40 | export * from './parse.js'; 41 | export * from './search.js'; 42 | export * from './resource.js'; 43 | export * from './store.js'; 44 | export * from './value.js'; 45 | export * from './urls.js'; 46 | export * from './truncate.js'; 47 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useDriveHistory.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '@tomic/react'; 2 | import { useCallback, useMemo } from 'react'; 3 | import { useSavedDrives } from './useSavedDrives'; 4 | 5 | const MAX_DRIVE_HISTORY = 5; 6 | 7 | export function useDriveHistory( 8 | filter: string[] = [], 9 | limit = Number.MAX_VALUE, 10 | ): [ 11 | driveHistory: string[], 12 | addDriveToHistory: (drive: string) => void, 13 | removeFromHistory: (drive: string) => void, 14 | ] { 15 | const [savedDrives] = useSavedDrives(); 16 | const [driveHistory, setDriveHistory] = useLocalStorage( 17 | 'driveHistory', 18 | [], 19 | ); 20 | 21 | const addDriveToHistory = useCallback( 22 | (drive: string) => { 23 | setDriveHistory(prev => { 24 | if (prev[0] === drive) { 25 | return prev; 26 | } 27 | 28 | return [drive, ...prev.filter(d => d !== drive)].slice( 29 | 0, 30 | MAX_DRIVE_HISTORY, 31 | ); 32 | }); 33 | }, 34 | [savedDrives, setDriveHistory], 35 | ); 36 | 37 | const removeFromHistory = useCallback( 38 | (drive: string) => { 39 | setDriveHistory(prev => prev.filter(d => d !== drive)); 40 | }, 41 | [setDriveHistory], 42 | ); 43 | 44 | const slicedAndFilteredHistory = useMemo( 45 | () => driveHistory.slice(0, limit).filter(d => !filter.includes(d)), 46 | [driveHistory, filter], 47 | ); 48 | 49 | return [slicedAndFilteredHistory, addDriveToHistory, removeFromHistory]; 50 | } 51 | -------------------------------------------------------------------------------- /data-browser/src/components/Collapse.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { timeoutEffect } from '../helpers/timeoutEffect'; 4 | import { animationDuration } from '../styling'; 5 | 6 | interface CollapseProps { 7 | open?: boolean; 8 | className?: string; 9 | } 10 | 11 | // the styling file is not loaded at boot so we have to use a function here 12 | const ANIMATION_DURATION = () => animationDuration * 1.5; 13 | 14 | export function Collapse({ 15 | open, 16 | className, 17 | children, 18 | }: React.PropsWithChildren): JSX.Element { 19 | const [mountChildren, setMountChildren] = useState(open); 20 | 21 | useEffect(() => { 22 | if (!open) { 23 | return timeoutEffect(() => { 24 | setMountChildren(false); 25 | }, ANIMATION_DURATION()); 26 | } 27 | 28 | setMountChildren(true); 29 | }, [open]); 30 | 31 | return ( 32 | 33 | {mountChildren && children} 34 | 35 | ); 36 | } 37 | 38 | interface GridCollapserProps { 39 | open?: boolean; 40 | } 41 | 42 | const GridCollapser = styled.div` 43 | display: grid; 44 | grid-template-rows: ${({ open }) => (open ? '1fr' : '0fr')}; 45 | transition: grid-template-rows ${() => ANIMATION_DURATION()}ms ease-in-out; 46 | 47 | @media (prefers-reduced-motion) { 48 | transition: unset; 49 | } 50 | `; 51 | 52 | const CollapseInner = styled.div` 53 | overflow: hidden; 54 | `; 55 | -------------------------------------------------------------------------------- /data-browser/src/views/BookmarkPage/BookmarkPreview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { ContainerNarrow } from '../../components/Containers.jsx'; 4 | import Markdown from '../../components/datatypes/Markdown.jsx'; 5 | import { ErrorLook } from '../../components/ErrorLook'; 6 | 7 | export interface BookmarkPreviewProps { 8 | preview: string; 9 | error?: Error; 10 | loading?: boolean; 11 | } 12 | 13 | export function BookmarkPreview({ 14 | preview, 15 | error, 16 | loading, 17 | }: BookmarkPreviewProps): JSX.Element { 18 | if (loading) { 19 | return loading...; 20 | } 21 | 22 | if (error) { 23 | return ; 24 | } 25 | 26 | if (!preview || preview === '') { 27 | return no preview...; 28 | } 29 | 30 | return ( 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | const ErrorPage = ({ error }) => { 38 | return ( 39 | 40 |
41 |

Could not load preview 😞

42 | {error.message} 43 |
44 |
45 | ); 46 | }; 47 | 48 | const CenterGrid = styled.div` 49 | display: grid; 50 | height: min(80vh, 1000px); 51 | width: 100%; 52 | place-items: center; 53 | font-size: calc(clamp(1rem, 5vw, 2.4rem) + 0.1rem); 54 | `; 55 | 56 | const StyledContainerNarrow = styled(ContainerNarrow)` 57 | max-width: 85ch; 58 | `; 59 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/InputSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { Datatype } from '@tomic/react'; 2 | import React from 'react'; 3 | import { InputProps } from './ResourceField'; 4 | import InputString from './InputString'; 5 | import { InputResource } from './InputResource'; 6 | import InputResourceArray from './InputResourceArray'; 7 | import InputMarkdown from './InputMarkdown'; 8 | import InputNumber from './InputNumber'; 9 | import InputBoolean from './InputBoolean'; 10 | 11 | /** Renders a fitting HTML input depending on the Datatype */ 12 | export default function InputSwitcher(props: InputProps): JSX.Element { 13 | switch (props.property.datatype) { 14 | case Datatype.STRING: { 15 | return ; 16 | } 17 | 18 | case Datatype.MARKDOWN: { 19 | return ; 20 | } 21 | 22 | case Datatype.SLUG: { 23 | return ; 24 | } 25 | 26 | case Datatype.INTEGER: { 27 | return ; 28 | } 29 | 30 | case Datatype.FLOAT: { 31 | return ; 32 | } 33 | 34 | case Datatype.ATOMIC_URL: { 35 | return ; 36 | } 37 | 38 | case Datatype.RESOURCEARRAY: { 39 | return ; 40 | } 41 | 42 | case Datatype.BOOLEAN: { 43 | return ; 44 | } 45 | 46 | // TODO: DateTime selector 47 | // case Datatype.TIMESTAMP: 48 | 49 | default: { 50 | return ; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/hooks/useSaveResource.ts: -------------------------------------------------------------------------------- 1 | import { Resource, useStore } from '@tomic/react'; 2 | import React, { useCallback, useState } from 'react'; 3 | import toast from 'react-hot-toast'; 4 | 5 | export type UseSaveResourceResult = [ 6 | save: (e: React.SyntheticEvent) => void, 7 | saving: boolean, 8 | error: Error | undefined, 9 | ]; 10 | 11 | /** 12 | * Hook that handles saving a resource that is being edited by a form. 13 | * 14 | * @param resource The resource to save. 15 | * @param onSaveSucces Callback that is called when the resource is saved successfully. 16 | */ 17 | export const useSaveResource = ( 18 | resource: Resource, 19 | onSaveSucces: () => void = () => void 0, 20 | ): UseSaveResourceResult => { 21 | const store = useStore(); 22 | const [saving, setSaving] = useState(false); 23 | const [error, setErr] = useState(undefined); 24 | 25 | const save = useCallback( 26 | async (e: React.SyntheticEvent) => { 27 | e.preventDefault(); 28 | setSaving(true); 29 | setErr(undefined); 30 | 31 | try { 32 | await resource.save(store); 33 | setSaving(false); 34 | onSaveSucces(); 35 | toast.success('Resource saved'); 36 | 37 | if (resource.new) { 38 | store.notifyResourceManuallyCreated(resource); 39 | } 40 | } catch (err) { 41 | setErr(err); 42 | setSaving(false); 43 | toast.error('Could not save resource'); 44 | } 45 | }, 46 | [resource, store], 47 | ); 48 | 49 | return [save, saving, error]; 50 | }; 51 | -------------------------------------------------------------------------------- /data-browser/public/app_data/images/mask-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 15 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /lerna-debug.log: -------------------------------------------------------------------------------- 1 | 0 silly argv { 2 | 0 silly argv _: [ 'run' ], 3 | 0 silly argv lernaVersion: '4.0.0', 4 | 0 silly argv '$0': '/var/folders/dc/qlq7mcj91dvg1833drz3xmy40000gn/T/fnm_multishell_73978_1623960627940/bin/lerna', 5 | 0 silly argv script: 'tsc' 6 | 0 silly argv } 7 | 1 notice cli v4.0.0 8 | 2 verbose rootPath /Users/joep/dev/src/github.com/atomicdata-dev/atomic-data-browser 9 | 3 error JSONError: Unexpected token "}" (0x7D) in JSON at position 163 while parsing near "... \"tsc\": \"tsc\",\n },\n \"author\": \"\",\n ..." in atomic-react/package.json 10 | 3 error 11 | 3 error   6 | "test": "echo \"Error: no test specified\" && exit 1", 12 | 3 error   7 | "tsc": "tsc", 13 | 3 error > 8 | }, 14 | 3 error   | ^ 15 | 3 error   9 | "author": "", 16 | 3 error   10 | "license": "ISC", 17 | 3 error   11 | "main": "src/index.ts" 18 | 3 error 19 | 3 error at parseJson (/Users/joep/dev/src/github.com/atomicdata-dev/atomic-data-browser/node_modules/parse-json/index.js:29:21) 20 | 3 error at parse (/Users/joep/dev/src/github.com/atomicdata-dev/atomic-data-browser/node_modules/load-json-file/index.js:15:9) 21 | 3 error at module.exports (/Users/joep/dev/src/github.com/atomicdata-dev/atomic-data-browser/node_modules/load-json-file/index.js:18:47) 22 | -------------------------------------------------------------------------------- /data-browser/src/components/ErrorLook.tsx: -------------------------------------------------------------------------------- 1 | import { lighten } from 'polished'; 2 | import styled from 'styled-components'; 3 | import React from 'react'; 4 | import { FaExclamationTriangle } from 'react-icons/fa'; 5 | 6 | export const ErrorLook = styled.span` 7 | color: ${props => props.theme.colors.alert}; 8 | font-family: monospace; 9 | line-height: 1.2rem; 10 | `; 11 | 12 | export interface ErrorBlockProps { 13 | error: Error; 14 | showTrace?: boolean; 15 | } 16 | 17 | export function ErrorBlock({ error, showTrace }: ErrorBlockProps): JSX.Element { 18 | return ( 19 | 20 | 21 | 22 | Something went wrong 23 | 24 | {error.message} 25 | {showTrace && ( 26 | <> 27 | Stack trace: 28 | {error.stack} 29 | 30 | )} 31 | 32 | ); 33 | } 34 | 35 | const ErrorLookBig = styled.div` 36 | color: ${p => p.theme.colors.alert}; 37 | font-size: 1rem; 38 | padding: ${p => p.theme.margin}rem; 39 | border-radius: ${p => p.theme.radius}; 40 | border: 1px solid ${p => lighten(0.2, p.theme.colors.alert)}; 41 | background-color: ${p => p.theme.colors.bg1}; 42 | `; 43 | 44 | const CodeBlock = styled.code` 45 | white-space: pre-wrap; 46 | border-radius: ${p => p.theme.radius}; 47 | padding: ${p => p.theme.margin}rem; 48 | background-color: ${p => p.theme.colors.bg}; 49 | `; 50 | 51 | const BiggerText = styled.p` 52 | font-size: 1.3rem; 53 | display: flex; 54 | align-items: center; 55 | gap: 1ch; 56 | `; 57 | -------------------------------------------------------------------------------- /data-browser/src/views/ResourceInline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useString, useResource, useTitle, urls, Client } from '@tomic/react'; 3 | import { AtomicLink } from '../components/AtomicLink'; 4 | import { ErrorLook } from '../components/ErrorLook'; 5 | import { LoaderInline } from '../components/Loader'; 6 | 7 | type Props = { 8 | subject: string; 9 | untabbable?: boolean; 10 | className?: string; 11 | }; 12 | 13 | /** Renders a Resource in a compact, inline link. Shows tooltip on hover. */ 14 | export function ResourceInline({ 15 | subject, 16 | untabbable, 17 | className, 18 | }: Props): JSX.Element { 19 | const resource = useResource(subject, { allowIncomplete: true }); 20 | const [title] = useTitle(resource); 21 | const [description] = useString(resource, urls.properties.description); 22 | 23 | if (!subject) { 24 | return No subject passed; 25 | } 26 | 27 | if (resource.error) { 28 | return ( 29 | 30 | 31 | Unknown Resource 32 | 33 | 34 | ); 35 | } 36 | 37 | if (resource.loading) { 38 | return ; 39 | } 40 | 41 | if (!Client.isValidSubject(subject)) { 42 | return {subject} is not a valid subject.; 43 | } 44 | 45 | return ( 46 | 47 | {title} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /data-browser/src/components/datatypes/ResourceArray.tsx: -------------------------------------------------------------------------------- 1 | import { JSONValue } from '@tomic/react'; 2 | import React, { useState } from 'react'; 3 | import styled from 'styled-components'; 4 | import { ResourceInline } from '../../views/ResourceInline'; 5 | 6 | type Props = { 7 | subjects: JSONValue[]; 8 | }; 9 | 10 | const MAX_COUNT = 10; 11 | 12 | /** Renders an array of subject URLs as links with commas between them */ 13 | function ResourceArray({ subjects: subjectsIn }: Props): JSX.Element { 14 | const [showAll, setShowMore] = useState(false); 15 | 16 | const tooMany = subjectsIn.length > MAX_COUNT; 17 | let subjects = subjectsIn; 18 | 19 | if (!showAll && tooMany) { 20 | subjects = subjects.slice(0, MAX_COUNT); 21 | } 22 | 23 | return ( 24 | <> 25 | {subjects.map((url, index) => { 26 | if (typeof url !== 'string') { 27 | console.warn(`ResourceArray: subject ${url} isn't a string`, url); 28 | 29 | return null; 30 | } 31 | 32 | return ( 33 | 34 | 35 | {index !== subjects.length - 1 && ', '} 36 | 37 | ); 38 | })} 39 | {tooMany && ( 40 | setShowMore(!showAll)}> 41 | {showAll ? 'show less' : `show ${subjectsIn.length - MAX_COUNT} more`} 42 | 43 | )} 44 | 45 | ); 46 | } 47 | 48 | const ShowMoreButton = styled.span` 49 | cursor: pointer; 50 | margin-left: 0.5em; 51 | 52 | &:hover { 53 | text-decoration: underline; 54 | } 55 | `; 56 | 57 | export default ResourceArray; 58 | -------------------------------------------------------------------------------- /debug.log: -------------------------------------------------------------------------------- 1 | [0211/153231.164:ERROR:registration_protocol_win.cc(130)] TransactNamedPipe: The pipe has been ended. (0x6D) 2 | [0211/153231.178:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 3 | [0211/153231.181:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 4 | [0211/185029.234:ERROR:registration_protocol_win.cc(130)] TransactNamedPipe: The pipe has been ended. (0x6D) 5 | [0211/185029.241:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 6 | [0211/185029.246:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 7 | [0211/191504.012:ERROR:registration_protocol_win.cc(130)] TransactNamedPipe: The pipe has been ended. (0x6D) 8 | [0211/191504.022:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 9 | [0211/191504.027:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 10 | [0313/121540.759:ERROR:registration_protocol_win.cc(130)] TransactNamedPipe: The pipe has been ended. (0x6D) 11 | [0313/121540.800:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 12 | [0313/121540.819:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 13 | [0313/125933.336:ERROR:registration_protocol_win.cc(130)] TransactNamedPipe: The pipe has been ended. (0x6D) 14 | [0313/125933.345:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 15 | [0313/125933.352:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 16 | [0313/130227.935:ERROR:registration_protocol_win.cc(130)] TransactNamedPipe: The pipe has been ended. (0x6D) 17 | [0313/130227.943:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 18 | [0313/130227.949:ERROR:file_io_win.cc(194)] LockFileEx: Incorrect function. (0x1) 19 | -------------------------------------------------------------------------------- /data-browser/src/routes/SettingsServer/FavoriteButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { FaRegStar, FaStar } from 'react-icons/fa'; 3 | import styled from 'styled-components'; 4 | import { useDriveHistory } from '../../hooks/useDriveHistory'; 5 | import { useSavedDrives } from '../../hooks/useSavedDrives'; 6 | 7 | interface FavoriteButtonProps { 8 | subject: string; 9 | className?: string; 10 | } 11 | 12 | export function FavoriteButton({ subject, className }: FavoriteButtonProps) { 13 | const [savedDrives, addSaveDrive, removeSaveDrive] = useSavedDrives(); 14 | const [_, addDriveToHistory, removeFromHistory] = useDriveHistory(); 15 | 16 | const isFavorite = savedDrives.includes(subject); 17 | const Icon = isFavorite ? FaStar : FaRegStar; 18 | 19 | const handleClick = useCallback(() => { 20 | if (isFavorite) { 21 | removeSaveDrive(subject); 22 | addDriveToHistory(subject); 23 | } else { 24 | addSaveDrive(subject); 25 | removeFromHistory(subject); 26 | } 27 | }, [ 28 | subject, 29 | savedDrives, 30 | removeFromHistory, 31 | addDriveToHistory, 32 | addSaveDrive, 33 | removeSaveDrive, 34 | ]); 35 | 36 | return ( 37 | 42 | 43 | 44 | ); 45 | } 46 | 47 | const StyledButton = styled.button` 48 | background: none; 49 | border: none; 50 | cursor: pointer; 51 | color: ${p => p.theme.colors.main}; 52 | width: 1.3rem; 53 | display: flex; 54 | align-items: center; 55 | padding: 0; 56 | `; 57 | -------------------------------------------------------------------------------- /data-browser/src/components/AllProps.tsx: -------------------------------------------------------------------------------- 1 | import { Resource } from '@tomic/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import PropVal from './PropVal'; 5 | 6 | type Props = { 7 | resource: Resource; 8 | /** A list of property subjects (URLs) that need not be rendered */ 9 | except?: string[]; 10 | /** If set to true, adds a button which opens up a form for each property */ 11 | editable?: boolean; 12 | /** 13 | * Render the properties in the left column, and the Values in the right one, 14 | * but only on large screens. 15 | */ 16 | columns?: boolean; 17 | }; 18 | 19 | const AllPropsWrapper = styled.div` 20 | margin-bottom: ${props => props.theme.margin}rem; 21 | `; 22 | 23 | /** Lists all PropVals for some resource. Optionally ignores a bunch of subjects */ 24 | function AllProps({ resource, except = [], editable, columns }: Props) { 25 | return ( 26 | 27 | {[...resource.getPropVals()].map( 28 | // This is a place where you might want to use the _val, because of performance. However, we currently don't, because of the form renderer. 29 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 30 | ([prop, _val]): JSX.Element => { 31 | if (except.includes(prop)) { 32 | return <>; 33 | } 34 | 35 | return ( 36 | 43 | ); 44 | }, 45 | )} 46 | 47 | ); 48 | } 49 | 50 | export default AllProps; 51 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useUpload.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AtomicError, 3 | properties, 4 | Resource, 5 | useArray, 6 | useStore, 7 | } from '@tomic/react'; 8 | import { useCallback, useState } from 'react'; 9 | 10 | export interface UseUploadResult { 11 | /** Uploads files to the upload endpoint and returns the created subjects. */ 12 | upload: (acceptedFiles: File[]) => Promise; 13 | isUploading: boolean; 14 | error: Error | undefined; 15 | } 16 | 17 | const opts = { 18 | commit: true, 19 | }; 20 | 21 | export function useUpload(parentResource: Resource): UseUploadResult { 22 | const store = useStore(); 23 | const [isUploading, setIsUploading] = useState(false); 24 | const [error, setError] = useState(undefined); 25 | const [subResources, setSubResources] = useArray( 26 | parentResource, 27 | properties.subResources, 28 | opts, 29 | ); 30 | 31 | const upload = useCallback( 32 | async (acceptedFiles: File[]) => { 33 | try { 34 | setError(undefined); 35 | setIsUploading(true); 36 | const netUploaded = await store.uploadFiles( 37 | acceptedFiles, 38 | parentResource.getSubject(), 39 | ); 40 | const allUploaded = [...netUploaded]; 41 | setIsUploading(false); 42 | setSubResources([...subResources, ...allUploaded]); 43 | 44 | return allUploaded; 45 | } catch (e) { 46 | setError(new AtomicError(e?.message)); 47 | setIsUploading(false); 48 | 49 | return []; 50 | } 51 | }, 52 | [parentResource, store, setSubResources, subResources], 53 | ); 54 | 55 | return { 56 | upload, 57 | isUploading, 58 | error, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /data-browser/src/views/File/FilePage.tsx: -------------------------------------------------------------------------------- 1 | import { properties } from '@tomic/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import { ContainerWide } from '../../components/Containers'; 5 | import { EditableTitle } from '../../components/EditableTitle'; 6 | import { ValueForm } from '../../components/forms/ValueForm'; 7 | import { Column, Row } from '../../components/Row'; 8 | import { useFileInfo } from '../../hooks/useFile'; 9 | import { useMediaQuery } from '../../hooks/useMediaQuery'; 10 | import { ResourcePageProps } from '../ResourcePage'; 11 | import { DownloadButton, DownloadIconButton } from './DownloadButton'; 12 | import { FilePreview } from './FilePreview'; 13 | 14 | /** Full page File resource for showing and downloading files */ 15 | export function FilePage({ resource }: ResourcePageProps) { 16 | const { downloadFile, bytes } = useFileInfo(resource); 17 | const wideScreen = useMediaQuery('(min-width: 600px)'); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | {wideScreen && ( 25 | 26 | )} 27 | {!wideScreen && ( 28 | 29 | )} 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | const StyledEditableTitle = styled(EditableTitle)` 39 | margin: 0; 40 | `; 41 | -------------------------------------------------------------------------------- /data-browser/src/views/Card/BookmarkCard.tsx: -------------------------------------------------------------------------------- 1 | import { urls, useString, useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import { AtomicLink } from '../../components/AtomicLink'; 5 | import Markdown from '../../components/datatypes/Markdown'; 6 | import { 7 | ExternalLink, 8 | ExternalLinkVariant, 9 | } from '../../components/ExternalLink'; 10 | import { CardViewProps } from './CardViewProps'; 11 | 12 | export function BookmarkCard({ resource }: CardViewProps): JSX.Element { 13 | const [title] = useTitle(resource); 14 | const [url] = useString(resource, urls.properties.bookmark.url); 15 | const [preview] = useString(resource, urls.properties.bookmark.preview); 16 | 17 | return ( 18 | <> 19 | 20 | {title} 21 | 22 | 23 | Open site 24 | 25 | {preview && ( 26 | 27 | 28 | 29 | )} 30 | 31 | ); 32 | } 33 | 34 | const Title = styled.h2` 35 | white-space: nowrap; 36 | overflow: hidden; 37 | text-overflow: ellipsis; 38 | line-height: 1.2; 39 | `; 40 | 41 | const MarkdownWrapper = styled.div` 42 | margin-top: ${p => p.theme.margin}rem; 43 | margin-inline: -${p => p.theme.margin}rem; 44 | padding: ${p => p.theme.margin}rem; 45 | background-color: ${props => props.theme.colors.bgBody}; 46 | border-top: 1px solid ${props => props.theme.colors.bg2}; 47 | 48 | img { 49 | border-radius: ${props => props.theme.radius}; 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/InputMarkdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useString } from '@tomic/react'; 3 | import { InputProps } from './ResourceField'; 4 | import { ErrMessage, InputWrapper } from './InputStyles'; 5 | import Yamde from 'yamde'; 6 | import { useSettings } from '../../helpers/AppSettings'; 7 | import styled from 'styled-components'; 8 | 9 | export default function InputMarkdown({ 10 | resource, 11 | property, 12 | ...props 13 | }: InputProps): JSX.Element { 14 | const [err, setErr] = useState(undefined); 15 | const [value, setVale] = useString(resource, property.subject, { 16 | handleValidationError: setErr, 17 | }); 18 | const { darkMode } = useSettings(); 19 | 20 | return ( 21 | <> 22 | 23 | 24 | setVale(e)} 27 | theme={darkMode ? 'dark' : 'light'} 28 | required={false} 29 | {...props} 30 | /> 31 | 32 | {/* */} 33 | 34 | {value !== '' && err && {err.message}} 35 | {value === '' && Required} 36 | 37 | ); 38 | } 39 | 40 | const YamdeStyling = styled.div` 41 | display: flex; 42 | flex: 1; 43 | 44 | .yamde-0-2-1 { 45 | margin: 0; 46 | } 47 | 48 | .contentArea-0-2-8 textarea, 49 | .preview-0-2-9 { 50 | background: ${p => p.theme.colors.bg}; 51 | font-size: ${p => p.theme.fontSizeBody}rem; 52 | } 53 | `; 54 | -------------------------------------------------------------------------------- /data-browser/src/components/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { FaExternalLinkAlt } from 'react-icons/fa'; 4 | 5 | export enum ExternalLinkVariant { 6 | Plain, 7 | Button, 8 | } 9 | 10 | export interface ExternalLinkProps { 11 | to: string; 12 | variant?: ExternalLinkVariant; 13 | } 14 | 15 | export function ExternalLink({ 16 | to, 17 | children, 18 | variant, 19 | }: React.PropsWithChildren): JSX.Element { 20 | const Comp = 21 | variant === ExternalLinkVariant.Button 22 | ? ExternalLinkButton 23 | : ExternalLinkPlain; 24 | 25 | return ( 26 | 27 | {children} 28 | 29 | 30 | ); 31 | } 32 | 33 | ExternalLink.defaultProps = { 34 | variant: ExternalLinkVariant.Plain, 35 | }; 36 | 37 | const ExternalLinkPlain = styled.a` 38 | display: flex; 39 | align-items: center; 40 | gap: 0.5rem; 41 | `; 42 | 43 | const ExternalLinkButton = styled.a` 44 | padding-inline: 0.8rem; 45 | padding-block: 0.4rem; 46 | width: fit-content; 47 | background-color: ${props => props.theme.colors.bg}; 48 | border: 1.5px solid ${props => props.theme.colors.main}; 49 | border-radius: ${p => p.theme.radius}; 50 | text-decoration: none; 51 | gap: 1ch; 52 | display: flex; 53 | align-items: center; 54 | font-weight: 600; 55 | justify-content: center; 56 | color: ${props => props.theme.colors.main}; 57 | white-space: nowrap; 58 | transition: 0.1s transform, 0.1s background-color, 0.1s box-shadow, 0.1s color; 59 | 60 | &:hover, 61 | &:focus-within { 62 | background-color: ${props => props.theme.colors.main}; 63 | color: white; 64 | } 65 | `; 66 | -------------------------------------------------------------------------------- /data-browser/src/components/ValueComp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Datatype, 4 | valToDate, 5 | valToString, 6 | valToArray, 7 | valToResource, 8 | JSONValue, 9 | } from '@tomic/react'; 10 | import { ResourceInline } from '../views/ResourceInline'; 11 | import { DateTime } from './datatypes/DateTime'; 12 | import Markdown from './datatypes/Markdown'; 13 | import Nestedresource from './datatypes/NestedResource'; 14 | import ResourceArray from './datatypes/ResourceArray'; 15 | import { ErrMessage } from './forms/InputStyles'; 16 | 17 | type Props = { 18 | value: JSONValue; 19 | datatype: Datatype; 20 | noMargin?: boolean; 21 | }; 22 | 23 | /** Renders a value in a fitting way, depending on its DataType */ 24 | function ValueComp({ value, datatype, noMargin }: Props): JSX.Element { 25 | try { 26 | switch (datatype) { 27 | case Datatype.ATOMIC_URL: { 28 | const resource = valToResource(value); 29 | 30 | if (typeof resource === 'string') { 31 | return ; 32 | } 33 | 34 | return ; 35 | } 36 | 37 | case (Datatype.DATE, Datatype.TIMESTAMP): 38 | return ; 39 | case Datatype.MARKDOWN: 40 | return ; 41 | case Datatype.RESOURCEARRAY: 42 | return ; 43 | default: 44 | return
{valToString(value)}
; 45 | } 46 | } catch (e) { 47 | return ( 48 | 49 | {e.message} original value: {value?.toString()} 50 | 51 | ); 52 | } 53 | } 54 | 55 | export default ValueComp; 56 | -------------------------------------------------------------------------------- /data-browser/src/components/MetaSetter.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | properties, 3 | unknownSubject, 4 | useResource, 5 | useString, 6 | useTitle, 7 | } from '@tomic/react'; 8 | import React from 'react'; 9 | import { Helmet } from 'react-helmet-async'; 10 | import { useSettings } from '../helpers/AppSettings'; 11 | import { useCurrentSubject } from '../helpers/useCurrentSubject'; 12 | 13 | /** Sets various HTML meta tags, depending on the currently opened resource */ 14 | export function MetaSetter(): JSX.Element { 15 | const { mainColor, darkMode } = useSettings(); 16 | const [subject] = useCurrentSubject(); 17 | const resource = useResource(subject); 18 | let [title] = useTitle(resource); 19 | let [description] = useString(resource, properties.description); 20 | const hasResource = 21 | resource.isReady() && resource.getSubject() !== unknownSubject; 22 | 23 | title = hasResource && title ? title : 'Atomic Data'; 24 | description = 25 | hasResource && description 26 | ? description 27 | : 'The easiest way to create and share linked data.'; 28 | 29 | return ( 30 | 31 | {title} 32 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /data-browser/src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import toast from 'react-hot-toast'; 3 | import { FaCheck, FaCopy } from 'react-icons/fa'; 4 | import styled from 'styled-components'; 5 | import { Button } from './Button'; 6 | 7 | interface CodeBlockProps { 8 | content?: string; 9 | loading?: boolean; 10 | } 11 | 12 | export function CodeBlock({ content, loading }: CodeBlockProps) { 13 | const [isCopied, setIsCopied] = useState(undefined); 14 | 15 | function copyToClipboard() { 16 | setIsCopied(content); 17 | navigator.clipboard.writeText(content || ''); 18 | toast.success('Copied to clipboard'); 19 | } 20 | 21 | return ( 22 | 23 | {loading ? ( 24 | 'loading...' 25 | ) : ( 26 | <> 27 | {content} 28 | 43 | 44 | )} 45 | 46 | ); 47 | } 48 | 49 | export const CodeBlockStyled = styled.pre` 50 | position: relative; 51 | background-color: ${p => p.theme.colors.bg1}; 52 | border-radius: ${p => p.theme.radius}; 53 | border: solid 1px ${p => p.theme.colors.bg2}; 54 | padding: 0.3rem; 55 | font-family: monospace; 56 | width: 100%; 57 | overflow-x: auto; 58 | `; 59 | -------------------------------------------------------------------------------- /data-browser/src/handlers/sideBarHandler.ts: -------------------------------------------------------------------------------- 1 | import { Resource, Store, urls, isString } from '@tomic/react'; 2 | 3 | export function buildSideBarNewResourceHandler(store: Store) { 4 | // When a resource is saved add it to the parents subResources list if it's not already there. 5 | return async (resource: Resource) => { 6 | const parentSubject = resource.get(urls.properties.parent); 7 | 8 | if (!isString(parentSubject)) { 9 | throw new Error( 10 | `Resource doesn't have a parent: ${resource.getSubject()} `, 11 | ); 12 | } 13 | 14 | const parent = await store.getResourceAsync(parentSubject); 15 | const subResources = parent.getSubjects(urls.properties.subResources); 16 | 17 | if (subResources.includes(resource.getSubject())) { 18 | return; 19 | } 20 | 21 | await parent.pushPropVal( 22 | urls.properties.subResources, 23 | resource.getSubject(), 24 | ); 25 | 26 | await parent.save(store); 27 | }; 28 | } 29 | 30 | export function buildSideBarRemoveResourceHandler(store: Store) { 31 | // When a resource is deleted remove it from the parents subResources list. 32 | return async (resource: Resource) => { 33 | const parentSubject = resource.get(urls.properties.parent); 34 | 35 | if (!isString(parentSubject)) { 36 | throw new Error( 37 | `Resource doesn't have a parent: ${resource.getSubject()} `, 38 | ); 39 | } 40 | 41 | const parent = await store.getResourceAsync(parentSubject); 42 | const subResources = parent.getSubjects(urls.properties.subResources); 43 | 44 | await parent.set( 45 | urls.properties.subResources, 46 | subResources.filter(r => r !== resource.getSubject()), 47 | store, 48 | ); 49 | 50 | parent.save(store); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /data-browser/src/views/README.md: -------------------------------------------------------------------------------- 1 | # Views Readme 2 | 3 | Views are a special type of components. 4 | They are the ones rendering the actual Resources. 5 | Views render only one or more classes. 6 | If there is no View available for a specific Class, it will fall back to the `ResourceX` component (e.g. `ResourceCard`). 7 | 8 | Some notes: 9 | 10 | - Every View is passed a `Resource` property. Some ViewType have additional properties, which should be documented here. 11 | - When naming a View, use the `ClassnameViewType.tsx` naming convention (e.g. `PersonCard`). 12 | - When adding a ViewType, document it here and implement a generic Resource renderer. Also make sure that it has error handling and adds the `about` RDFa attribute. 13 | - Views starting with `Resource` in the name are responsible for registering the other class specific Views. 14 | 15 | ## View Types 16 | 17 | Since views will occur in some context (e.g. full page vs inside a small card), they need to be registered for a certain View Type. 18 | The following view types currently exist, from large to small: 19 | 20 | ### Page 21 | 22 | A full page Resource. 23 | This is what is shown when opening the URL of the resource. 24 | 25 | ### Card 26 | 27 | A smaller, contained version. Shown in grid views and in search results. 28 | 29 | Properties: 30 | 31 | - `small`: boolean. Will hide even more elements. 32 | - `selected`: boolean. Adds a border to the item. 33 | 34 | ### Line 35 | 36 | A Resource inside a single (full width) line. 37 | Used in lists. 38 | 39 | ### Inline 40 | 41 | Can appear inside a sentence of text, or inside a table. 42 | One of the smallest View Types. 43 | 44 | ## Adding a new View 45 | 46 | Depending on the ViewType, make sure to add your new component to the respective `switch` statement in e.g. `ResourcePage` or `ResourceCard`. 47 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/NewForm/NewFormTitle.tsx: -------------------------------------------------------------------------------- 1 | import { properties, useResource, useString, useTitle } from '@tomic/react'; 2 | import React, { useState } from 'react'; 3 | import { FaInfo } from 'react-icons/fa'; 4 | import { AtomicLink } from '../../AtomicLink'; 5 | import { Button } from '../../Button'; 6 | import Markdown from '../../datatypes/Markdown'; 7 | 8 | export enum NewFormTitleVariant { 9 | FullPage, 10 | Dialog, 11 | } 12 | 13 | export interface NewFormTitleProps { 14 | variant?: NewFormTitleVariant; 15 | /** The URL of the Class, if available */ 16 | classSubject?: string; 17 | } 18 | 19 | const variantHeaderMapping = new Map< 20 | NewFormTitleVariant, 21 | keyof JSX.IntrinsicElements 22 | >([ 23 | [NewFormTitleVariant.FullPage, 'h2'], 24 | [NewFormTitleVariant.Dialog, 'h1'], 25 | ]); 26 | 27 | export const NewFormTitle: React.FC = ({ 28 | classSubject, 29 | variant, 30 | }) => { 31 | const klass = useResource(classSubject); 32 | const [klassTitle] = useTitle(klass); 33 | 34 | const [klassDescription] = useString(klass, properties.description); 35 | const [showDetails, setShowDetails] = useState(false); 36 | 37 | const HeadingComp = variantHeaderMapping.get(variant!) ?? 'h2'; 38 | 39 | return ( 40 | <> 41 | 42 | new{' '} 43 | {classSubject ? ( 44 | {klassTitle} 45 | ) : ( 46 | 'Resource' 47 | )} 48 | 56 | 57 | {showDetails && klassDescription && } 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /data-browser/src/components/SideBar/About.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Logo } from '../Logo'; 3 | import { SideBarHeader } from './SideBarHeader'; 4 | import React from 'react'; 5 | import { FaGithub, FaDiscord, FaBook } from 'react-icons/fa'; 6 | import { IconButtonLink } from '../IconButton/IconButton'; 7 | 8 | interface AboutItem { 9 | icon: React.ReactNode; 10 | helper: string; 11 | href: string; 12 | } 13 | 14 | const aboutMenuItems: AboutItem[] = [ 15 | { 16 | icon: , 17 | helper: 'Github; View the source code for this application', 18 | href: 'https://github.com/atomicdata-dev/atomic-data-browser', 19 | }, 20 | { 21 | icon: , 22 | helper: 'Discord; Chat with the Atomic Data community', 23 | href: 'https://discord.gg/a72Rv2P', 24 | }, 25 | { 26 | icon: , 27 | helper: 'Docs; Read the Atomic Data documentation', 28 | href: 'https://docs.atomicdata.dev', 29 | }, 30 | ]; 31 | 32 | export function About() { 33 | return ( 34 | <> 35 | 36 | 37 | 38 | 39 | {aboutMenuItems.map(({ href, icon, helper }) => ( 40 | 49 | {icon} 50 | 51 | ))} 52 | 53 | 54 | ); 55 | } 56 | 57 | const AboutWrapper = styled.div` 58 | --inner-padding: 0.5rem; 59 | display: flex; 60 | /* flex-direction: column; */ 61 | align-items: center; 62 | gap: 0.5rem; 63 | margin-left: calc(1rem - var(--inner-padding)); 64 | `; 65 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useSavedDrives.ts: -------------------------------------------------------------------------------- 1 | import { urls, useArray, useResource, useStore } from '@tomic/react'; 2 | import { useCallback, useMemo } from 'react'; 3 | import { isDev } from '../config'; 4 | import { useSettings } from '../helpers/AppSettings'; 5 | 6 | const rootDrives = [ 7 | window.location.origin, 8 | 'https://atomicdata.dev', 9 | ...(isDev() ? ['http://localhost:9883'] : []), 10 | ]; 11 | 12 | const arrayOpts = { 13 | commit: true, 14 | }; 15 | 16 | export function useSavedDrives(): [ 17 | savedDrives: string[], 18 | add: (drive: string) => void, 19 | remove: (drive: string) => void, 20 | ] { 21 | const { agent } = useSettings(); 22 | const store = useStore(); 23 | const agentResource = useResource(agent?.subject); 24 | const [drives, setDrives] = useArray( 25 | agentResource, 26 | urls.properties.drives, 27 | arrayOpts, 28 | ); 29 | 30 | const extraDrives = useMemo(() => [...rootDrives, ...drives], [drives]); 31 | 32 | const add = useCallback( 33 | (drive: string) => { 34 | // Don't do anything if the drive is hardcoded into the list. 35 | if (rootDrives.includes(drive)) { 36 | return; 37 | } 38 | 39 | if (!drives.includes(drive)) { 40 | setDrives([...drives, drive]).then(() => { 41 | agentResource.save(store); 42 | }); 43 | } 44 | }, 45 | [drives, setDrives], 46 | ); 47 | 48 | const remove = useCallback( 49 | (drive: string) => { 50 | // Don't do anything if the drive is hardcoded into the list. 51 | if (rootDrives.includes(drive)) { 52 | return; 53 | } 54 | 55 | if (drives.includes(drive)) { 56 | setDrives(drives.filter(d => d !== drive)).then(() => { 57 | agentResource.save(store); 58 | }); 59 | } 60 | }, 61 | [drives, setDrives], 62 | ); 63 | 64 | return [extraDrives, add, remove]; 65 | } 66 | -------------------------------------------------------------------------------- /data-browser/src/components/SideBar/ResourceSideBar/FloatingActions.tsx: -------------------------------------------------------------------------------- 1 | import { useResource, useTitle } from '@tomic/react'; 2 | import React from 'react'; 3 | import { FaEllipsisV, FaPlus } from 'react-icons/fa'; 4 | import styled, { css } from 'styled-components'; 5 | import { useNewRoute } from '../../../helpers/useNewRoute'; 6 | import { buildDefaultTrigger } from '../../Dropdown/DefaultTrigger'; 7 | import { IconButton } from '../../IconButton/IconButton'; 8 | import ResourceContextMenu from '../../ResourceContextMenu'; 9 | 10 | export interface FloatingActionsProps { 11 | subject: string; 12 | className?: string; 13 | } 14 | 15 | /** Contains actions for a SideBarResource, such as a context menu and a new item button */ 16 | export function FloatingActions({ 17 | subject, 18 | className, 19 | }: FloatingActionsProps): JSX.Element { 20 | const parentResource = useResource(subject); 21 | const [parentName] = useTitle(parentResource); 22 | 23 | const handleAddClick = useNewRoute(subject); 24 | 25 | return ( 26 | 27 | 32 | 33 | 34 | 39 | 40 | ); 41 | } 42 | 43 | const Wrapper = styled.span` 44 | visibility: hidden; 45 | display: none; 46 | font-size: 0.9rem; 47 | color: ${p => p.theme.colors.main}; 48 | `; 49 | 50 | export const floatingHoverStyles = css` 51 | position: relative; 52 | 53 | &:hover ${Wrapper}, &:focus-within ${Wrapper} { 54 | visibility: visible; 55 | display: inline; 56 | } 57 | `; 58 | 59 | const SideBarDropDownTrigger = buildDefaultTrigger(); 60 | -------------------------------------------------------------------------------- /data-browser/src/views/FolderPage/GridView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaPlus } from 'react-icons/fa'; 3 | import styled from 'styled-components'; 4 | import { ViewProps } from './FolderDisplayStyle'; 5 | import { 6 | GridCard, 7 | GridItemTitle, 8 | GridItemWrapper, 9 | } from './GridItem/components'; 10 | import { ResourceGridItem } from './GridItem/ResourceGridItem'; 11 | 12 | export function GridView({ 13 | subResources, 14 | onNewClick, 15 | showNewButton, 16 | }: ViewProps): JSX.Element { 17 | return ( 18 | 19 | {Array.from(subResources.values()).map(resource => ( 20 | 24 | ))} 25 | {showNewButton && ( 26 | 27 | 28 | 29 | 30 | New Resource 31 | 32 | )} 33 | 34 | ); 35 | } 36 | 37 | const Grid = styled.div` 38 | display: grid; 39 | grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); 40 | width: var(--container-width); 41 | margin-inline: auto; 42 | gap: 3rem; 43 | `; 44 | 45 | const NewCard = styled(GridCard)` 46 | background-color: ${p => p.theme.colors.bg1}; 47 | border: 1px solid ${p => p.theme.colors.bg2}; 48 | cursor: pointer; 49 | display: grid; 50 | place-items: center; 51 | font-size: 3rem; 52 | color: ${p => p.theme.colors.textLight}; 53 | transition: color 0.1s ease-in-out, font-size 0.1s ease-out, 54 | box-shadow 0.1s ease-in-out; 55 | ${GridItemWrapper}:hover &, 56 | ${GridItemWrapper}:focus & { 57 | color: ${p => p.theme.colors.main}; 58 | font-size: 3.8rem; 59 | } 60 | 61 | :active { 62 | font-size: 3rem; 63 | } 64 | `; 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@esm-bundle/chai": "4.3.4", 4 | "@jest/globals": "^29.3.1", 5 | "@playwright/test": "^1.29.0", 6 | "@types/chai": "^4.2.22", 7 | "@types/jest": "^27.0.2", 8 | "@types/node": "^16.11.4", 9 | "@types/react": "^18.0.20", 10 | "@types/react-router-dom": "^5.0.0", 11 | "@typescript-eslint/eslint-plugin": "^5.9.0", 12 | "@typescript-eslint/parser": "^5.9.0", 13 | "@vitejs/plugin-react": "^1.3.0", 14 | "chai": "^4.3.4", 15 | "eslint": "8.23.0", 16 | "eslint-config-prettier": "^8.3.0", 17 | "eslint-plugin-jsx-a11y": "^6.6.1", 18 | "eslint-plugin-prettier": "^4.0.0", 19 | "eslint-plugin-react": "^7.28.0", 20 | "eslint-plugin-react-hooks": "^4.3.0", 21 | "husky": "^7.0.4", 22 | "jest": "^29.0.2", 23 | "prettier": "2.4.1", 24 | "prettier-plugin-jsdoc": "^0.3.24", 25 | "react": "^18.2.0", 26 | "ts-jest": "^29.0.1", 27 | "typedoc": "^0.23.14", 28 | "typedoc-plugin-missing-exports": "^1.0.0", 29 | "typescript": "^4.8", 30 | "vite": "^3.0.5" 31 | }, 32 | "name": "@tomic/root", 33 | "private": true, 34 | "type": "module", 35 | "scripts": { 36 | "dev": "pnpm run start", 37 | "lint": "pnpm run -r lint", 38 | "lint-fix": "pnpm run -r lint-fix", 39 | "build": "pnpm run -r build", 40 | "test": "pnpm run -r test", 41 | "test-query": "pnpm run --filter @tomic/data-browser test-query", 42 | "start": "pnpm run -r --parallel start", 43 | "typedoc": "typedoc . --options typedoc.json", 44 | "typecheck": "pnpm run -r --parallel typecheck", 45 | "playwright-install": "playwright install" 46 | }, 47 | "workspaces": { 48 | "packages": [ 49 | "lib", 50 | "react", 51 | "data-browser" 52 | ] 53 | }, 54 | "packageManager": "pnpm@7.13.3", 55 | "dependencies": { 56 | "eslint-plugin-import": "^2.26.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /data-browser/src/components/NetworkIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | import { MdSignalWifiOff } from 'react-icons/md'; 4 | import { useOnline } from '../hooks/useOnline'; 5 | import { lighten } from 'polished'; 6 | import toast from 'react-hot-toast'; 7 | 8 | export function NetworkIndicator() { 9 | const isOnline = useOnline(); 10 | 11 | useEffect(() => { 12 | if (!isOnline) { 13 | toast.error('You are offline, changes might not be persisted.'); 14 | } 15 | }, [isOnline]); 16 | 17 | return ( 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | interface WrapperProps { 25 | shown: boolean; 26 | } 27 | 28 | const pulse = keyframes` 29 | 0% { 30 | opacity: 1; 31 | filter: drop-shadow(0 0 5px var(--shadow-color)); 32 | } 33 | 100% { 34 | opacity: 0.8; 35 | filter: drop-shadow(0 0 0 var(--shadow-color)); 36 | } 37 | `; 38 | 39 | const Wrapper = styled.div` 40 | --shadow-color: ${p => lighten(0.15, p.theme.colors.alert)}; 41 | position: fixed; 42 | bottom: 1.2rem; 43 | right: 2rem; 44 | z-index: ${({ theme }) => theme.zIndex.networkIndicator}; 45 | font-size: 1.5rem; 46 | color: ${p => p.theme.colors.alert}; 47 | pointer-events: ${p => (p.shown ? 'auto' : 'none')}; 48 | transition: opacity 0.1s ease-in-out; 49 | opacity: ${p => (p.shown ? 1 : 0)}; 50 | 51 | background-color: ${p => p.theme.colors.bg}; 52 | border: 1px solid ${p => p.theme.colors.alert}; 53 | border-radius: 50%; 54 | display: grid; 55 | place-items: center; 56 | box-shadow: ${p => p.theme.boxShadowSoft}; 57 | padding: 0.5rem; 58 | 59 | svg { 60 | animation: ${pulse} 1.5s alternate ease-in-out infinite; 61 | animation-play-state: ${p => (p.shown ? 'running' : 'paused')}; 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /data-browser/src/components/NewInstanceButton/Base.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '@tomic/react'; 2 | import React, { useCallback } from 'react'; 3 | import toast from 'react-hot-toast'; 4 | import { IconType } from 'react-icons'; 5 | import { FaPlus } from 'react-icons/fa'; 6 | import { useNavigate } from 'react-router-dom'; 7 | import styled from 'styled-components'; 8 | import { paths } from '../../routes/paths'; 9 | import { Button } from '../Button'; 10 | 11 | export interface InstanceButtonBaseProps { 12 | onClick: () => void; 13 | subtle?: boolean; 14 | title: string; 15 | icon?: boolean; 16 | IconComponent?: IconType; 17 | label?: string; 18 | className?: string; 19 | } 20 | 21 | export function Base({ 22 | children, 23 | subtle, 24 | title, 25 | icon, 26 | onClick, 27 | IconComponent, 28 | label, 29 | className, 30 | }: React.PropsWithChildren): JSX.Element { 31 | const store = useStore(); 32 | const agent = store.getAgent(); 33 | 34 | const navigate = useNavigate(); 35 | 36 | const handleClick = useCallback(() => { 37 | if (!agent) { 38 | toast.error('You need to be logged in to create new things'); 39 | navigate(paths.agentSettings); 40 | 41 | return; 42 | } 43 | 44 | onClick(); 45 | }, [agent, navigate]); 46 | 47 | const Icon = IconComponent ?? FaPlus; 48 | 49 | return ( 50 | 66 | ); 67 | } 68 | 69 | const IconWrapper = styled.div` 70 | display: flex; 71 | align-items: center; 72 | gap: 0.5rem; 73 | `; 74 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface CheckboxProps 5 | extends Omit< 6 | React.InputHTMLAttributes, 7 | 'type' | 'onChange' 8 | > { 9 | checked?: boolean; 10 | onChange: (value: boolean) => void; 11 | } 12 | 13 | export function Checkbox({ 14 | checked, 15 | onChange, 16 | ...props 17 | }: CheckboxProps): JSX.Element { 18 | const handleChange = (e: React.ChangeEvent) => { 19 | onChange(e.target.checked); 20 | }; 21 | 22 | return ( 23 | 29 | ); 30 | } 31 | 32 | const InputCheckBox = styled.input` 33 | --inset: 1px; 34 | --size: calc(100% - (var(--inset) * 2)); 35 | 36 | background-color: ${p => p.theme.colors.bg1}; 37 | appearance: none; 38 | border: 1px solid ${p => p.theme.colors.bg2}; 39 | width: 1rem; 40 | height: 1rem; 41 | border-radius: 3px; 42 | 43 | position: relative; 44 | 45 | :checked { 46 | border: none; 47 | } 48 | 49 | :checked::before { 50 | content: ''; 51 | position: absolute; 52 | inset: 0; 53 | width: 100%; 54 | height: 100%; 55 | border-radius: 2px; 56 | background-color: ${p => p.theme.colors.main}; 57 | } 58 | 59 | :checked::after { 60 | --inset: 3px; 61 | --size: calc(100% - (var(--inset) * 2)); 62 | content: ''; 63 | position: absolute; 64 | inset: var(--inset); 65 | width: var(--size); 66 | height: var(--size); 67 | background-color: ${p => p.theme.colors.bg}; 68 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 69 | } 70 | `; 71 | 72 | export const CheckboxLabel = styled.label` 73 | display: flex; 74 | align-items: center; 75 | gap: 0.5rem; 76 | cursor: pointer; 77 | `; 78 | -------------------------------------------------------------------------------- /lib/src/EventManager.ts: -------------------------------------------------------------------------------- 1 | type Handlers = { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | [key in Types]: (...args: any[]) => any; 4 | }; 5 | 6 | /** Event manger, used to manage events and dispatch events to the correct handlers. */ 7 | export class EventManager> { 8 | private subscriptions = new Map>(); 9 | 10 | public register(event: T, handler: H[T]) { 11 | const handlers = this.subscriptions.get(event) ?? new Set(); 12 | handlers.add(handler); 13 | 14 | this.subscriptions.set(event, handlers); 15 | 16 | return () => { 17 | handlers.delete(handler); 18 | }; 19 | } 20 | 21 | public async emit( 22 | event: T, 23 | ...args: Parameters 24 | ): Promise { 25 | if (!this.subscriptions.has(event)) return; 26 | 27 | const handlers = this.subscriptions.get(event); 28 | 29 | const wrap = async (handler: H[Types]) => { 30 | handler(...args); 31 | 32 | return; 33 | }; 34 | 35 | if (!handlers) { 36 | return; 37 | } 38 | 39 | await Promise.allSettled([...handlers].map(handler => wrap(handler))); 40 | } 41 | 42 | public hasSubscriptions(event: T): boolean { 43 | return this.subscriptions.has(event); 44 | } 45 | } 46 | 47 | /* EXAMPLE: 48 | 49 | type EventTypes = 'exampleStart' | 'exampleEnd'; 50 | 51 | type EventHandlers = { 52 | exampleStart: (message: string) => void; 53 | exampleEnd: (animals: boolean[]) => void; 54 | }; 55 | 56 | class Example { 57 | private emitter = new EventManager(); 58 | 59 | public on(event: T, cb: EventHandlers[T]) { 60 | return this.emitter.register(event, cb); 61 | } 62 | 63 | public doSomething() { 64 | this.emitter.emit('exampleEnd', [true]); 65 | } 66 | } 67 | */ 68 | -------------------------------------------------------------------------------- /data-browser/src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { transitionName } from '../helpers/transitionName'; 3 | 4 | type CardProps = { 5 | /** Adds a colorful border */ 6 | highlight?: boolean; 7 | /** Sets a maximum height */ 8 | small?: boolean; 9 | }; 10 | 11 | /** A Card with a border. */ 12 | export const Card = styled.div` 13 | background-color: ${props => props.theme.colors.bg}; 14 | /** Don't put side margins in this component - use a wrapping component */ 15 | border: solid 1px ${props => props.theme.colors.bg2}; 16 | box-shadow: ${props => props.theme.boxShadow}; 17 | padding: ${props => props.theme.margin}rem; 18 | /* margin-bottom: ${props => props.theme.margin}rem; */ 19 | padding-bottom: 0; 20 | border-radius: ${props => props.theme.radius}; 21 | max-height: ${props => (props.small ? '10rem' : 'none')}; 22 | overflow: ${props => (props.small ? 'hidden' : 'visible')}; 23 | border-color: ${props => 24 | props.highlight ? props.theme.colors.main : props.theme.colors.bg2}; 25 | 26 | ${p => transitionName('resource-page', p.about)}; 27 | `; 28 | 29 | export interface CardRowProps { 30 | noBorder?: boolean; 31 | } 32 | 33 | /** A Row in a Card. Should probably be used inside a CardInsideFull */ 34 | export const CardRow = styled.div` 35 | --border: solid 1px ${props => props.theme.colors.bg2}; 36 | display: block; 37 | border-top: ${props => (props.noBorder ? 'none' : 'var(--border)')}; 38 | padding: ${props => props.theme.margin / 3}rem 39 | ${props => props.theme.margin}rem; 40 | `; 41 | 42 | /** A block inside a Card which has full width */ 43 | export const CardInsideFull = styled.div` 44 | margin-left: -${props => props.theme.margin}rem; 45 | margin-right: -${props => props.theme.margin}rem; 46 | `; 47 | 48 | export const Margin = styled.div` 49 | display: block; 50 | height: ${props => props.theme.margin}rem; 51 | `; 52 | -------------------------------------------------------------------------------- /data-browser/src/routes/ShortcutsRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { ContainerNarrow } from '../components/Containers'; 4 | import { shortcuts } from '../components/HotKeyWrapper'; 5 | import { Shortcut } from '../components/Shortcut'; 6 | 7 | /** List of all the keyboard shorcuts */ 8 | export const Shortcuts: React.FunctionComponent = () => { 9 | return ( 10 | 11 |

Keyboard shortcuts

12 |

Global

13 |

14 | Search 15 |

16 |

17 | Show or hide the sidebar 18 |

19 |

20 | Show these keyboard shortcuts 21 |

22 |

23 | Edit resource 24 |

25 |

26 | Show data for resource 27 |

28 |

29 | Show home page 30 |

31 |

32 | New resource 33 |

34 |

35 | Open menu 36 |

37 |

38 | User settings 39 |

40 |

41 | Theme settings 42 |

43 |

Document

44 |

45 | Move line / section up 46 |

47 |

48 | Move line / section down 49 |

50 |

51 | Delete line 52 |

53 |
54 | ); 55 | }; 56 | 57 | const Key = styled(Shortcut)` 58 | font-size: 1rem; 59 | `; 60 | -------------------------------------------------------------------------------- /data-browser/src/routes/SettingsServer/DrivesCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NewIntanceButton from '../../components/NewInstanceButton'; 3 | import { Card, CardInsideFull, CardRow } from '../../components/Card'; 4 | import { urls } from '@tomic/react'; 5 | import styled from 'styled-components'; 6 | import { useSettings } from '../../helpers/AppSettings'; 7 | import { DriveRow } from './DriveRow'; 8 | 9 | export interface DriveCardProps { 10 | drives: string[]; 11 | onDriveSelect: (drive: string) => void; 12 | showNewOption?: boolean; 13 | } 14 | 15 | export function DrivesCard({ 16 | drives, 17 | onDriveSelect, 18 | showNewOption, 19 | }: DriveCardProps): JSX.Element { 20 | const { drive } = useSettings(); 21 | 22 | if (drives.length === 0) { 23 | return Nothing to show; 24 | } 25 | 26 | return ( 27 | 28 | 29 | {drives.map((subject, i) => { 30 | return ( 31 | 32 | 37 | 38 | ); 39 | })} 40 | {showNewOption && ( 41 | 42 | 48 | 49 | )} 50 | 51 | 52 | ); 53 | } 54 | 55 | const ContainerCard = styled(Card)` 56 | container-type: inline-size; 57 | padding-top: 0; 58 | `; 59 | 60 | const StyledNewInstanceButton = styled(NewIntanceButton)` 61 | border: none; 62 | box-shadow: none; 63 | padding: 0; 64 | 65 | &&:hover, 66 | &&:focus { 67 | box-shadow: none; 68 | } 69 | `; 70 | -------------------------------------------------------------------------------- /data-browser/src/views/ClassPage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | classToTypescriptDefinition, 3 | properties, 4 | useStore, 5 | } from '@tomic/react'; 6 | import React, { useState } from 'react'; 7 | import AllProps from '../components/AllProps'; 8 | import { Button } from '../components/Button'; 9 | import { ClassDetail } from '../components/ClassDetail'; 10 | import { CodeBlock } from '../components/CodeBlock'; 11 | import { ContainerNarrow } from '../components/Containers'; 12 | import { ValueForm } from '../components/forms/ValueForm'; 13 | import NewInstanceButton from '../components/NewInstanceButton'; 14 | import { Title } from '../components/Title'; 15 | import { Row } from '../components/Row'; 16 | import { ResourcePageProps } from './ResourcePage'; 17 | import { defaultHiddenProps } from './ResourcePageDefault'; 18 | 19 | /** 20 | * Full page Class resoure that features a New instance button, and a Typescript 21 | * definition export. 22 | */ 23 | export function ClassPage({ resource }: ResourcePageProps) { 24 | const [tsDef, setTSdef] = useState(undefined); 25 | const store = useStore(); 26 | 27 | return ( 28 | 29 | 30 | <ClassDetail resource={resource} /> 31 | <ValueForm resource={resource} propertyURL={properties.description} /> 32 | <AllProps 33 | resource={resource} 34 | except={defaultHiddenProps} 35 | editable 36 | columns 37 | /> 38 | <Row> 39 | <NewInstanceButton icon={true} klass={resource.getSubject()} /> 40 | <Button 41 | subtle 42 | onClick={async () => 43 | setTSdef(await classToTypescriptDefinition(resource, store)) 44 | } 45 | > 46 | typescript interface 47 | </Button> 48 | </Row> 49 | {tsDef && <CodeBlock content={tsDef} />} 50 | </ContainerNarrow> 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /data-browser/src/chunks/PDFViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useState } from 'react'; 2 | import { pdfjs, Document, Page } from 'react-pdf'; 3 | import 'react-pdf/dist/esm/Page/TextLayer.css'; 4 | import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; 5 | import styled from 'styled-components'; 6 | 7 | pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`; 8 | interface PDFViewerProps { 9 | url: string; 10 | className?: string; 11 | } 12 | 13 | export default function PDFViewer({ 14 | url, 15 | className, 16 | }: PDFViewerProps): JSX.Element { 17 | const [numberOfPages, setNumberOfPages] = useState<number>(0); 18 | const handleError = useCallback((error: Error) => console.error(error), []); 19 | 20 | const handleDocumentLoadSuccess = useCallback( 21 | ({ numPages }: { numPages: number }) => { 22 | setNumberOfPages(numPages); 23 | }, 24 | [], 25 | ); 26 | 27 | const file = useMemo(() => { 28 | return { 29 | url: url, 30 | withCredentials: true, 31 | }; 32 | }, [url]); 33 | 34 | return ( 35 | <StyledDocument 36 | file={file} 37 | className={className} 38 | onLoadSuccess={handleDocumentLoadSuccess} 39 | onLoadError={handleError} 40 | onSourceError={handleError} 41 | > 42 | {Array.from(new Array(numberOfPages), (el, index) => ( 43 | <StyledPage key={`page_${index + 1}`} pageNumber={index + 1} /> 44 | ))} 45 | </StyledDocument> 46 | ); 47 | } 48 | 49 | const StyledDocument = styled(Document)` 50 | display: flex; 51 | flex-direction: column; 52 | gap: 1rem; 53 | width: 100%; 54 | overflow-x: auto; 55 | overflow-y: visible; 56 | padding-bottom: 1rem; 57 | `; 58 | 59 | const StyledPage = styled(Page)` 60 | margin: auto; 61 | border-radius: ${({ theme }) => theme.radius}; 62 | overflow: hidden; 63 | box-shadow: ${({ theme }) => theme.boxShadow}; 64 | `; 65 | -------------------------------------------------------------------------------- /data-browser/src/hooks/useClickAwayListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | type RefList = Array<React.RefObject<HTMLElement | null>>; 4 | type SupportedEvents = 'click' | 'mouseout' | 'mousedown'; 5 | 6 | const elementsContainTarget = (refs: RefList, target: HTMLElement) => 7 | refs 8 | .filter(r => r.current) 9 | .some(ref => ref.current === target || ref.current?.contains(target)); 10 | 11 | const addListeners = ( 12 | types: SupportedEvents[], 13 | hanlder: (e: MouseEvent) => void, 14 | ) => { 15 | types.forEach(type => window.addEventListener(type, hanlder)); 16 | }; 17 | 18 | const removeListeners = ( 19 | types: SupportedEvents[], 20 | hanlder: (e: MouseEvent) => void, 21 | ) => { 22 | types.forEach(type => window.removeEventListener(type, hanlder)); 23 | }; 24 | 25 | /** 26 | * Detects when a user clicks outside of any of the given elements. 27 | * 28 | * @param refs List of element refs that will not trigger the listener when clicked. 29 | * @param onClickAway Callback that will be called when the user clicks outside 30 | * of any of the given elements. 31 | * @param shouldListen When false the callback will not be called. 32 | * @param eventTypes List of events that will trigger the listener. 33 | */ 34 | export const useClickAwayListener = ( 35 | refs: RefList, 36 | onClickAway: () => void, 37 | shouldListen = true, 38 | eventTypes: SupportedEvents[] = ['mousedown'], 39 | ): void => { 40 | useEffect(() => { 41 | const onClick = (e: MouseEvent) => { 42 | if ( 43 | shouldListen && 44 | !elementsContainTarget(refs, e.target as HTMLElement) 45 | ) { 46 | e.preventDefault(); 47 | onClickAway(); 48 | removeListeners(eventTypes, onClick); 49 | } 50 | }; 51 | 52 | addListeners(eventTypes, onClick); 53 | 54 | return () => { 55 | removeListeners(eventTypes, onClick); 56 | }; 57 | }, [refs, onClickAway, shouldListen]); 58 | }; 59 | -------------------------------------------------------------------------------- /data-browser/src/routes/History/versionHelpers.ts: -------------------------------------------------------------------------------- 1 | import { Resource, Store, Version } from '@tomic/react'; 2 | 3 | const groupFormatter = new Intl.DateTimeFormat('default', { 4 | month: 'long', 5 | year: 'numeric', 6 | }); 7 | 8 | /** Removes back to back duplicate versions */ 9 | export function dedupeVersions(versions: Version[]): Version[] { 10 | return versions.filter((v, i) => { 11 | if (i === 0) { 12 | return true; 13 | } 14 | 15 | const prev = versions[i - 1]; 16 | 17 | if (v.commit.signer !== prev.commit.signer) { 18 | return true; 19 | } 20 | 21 | return resourceToString(v.resource) !== resourceToString(prev.resource); 22 | }); 23 | } 24 | 25 | export async function setResourceToVersion( 26 | resource: Resource, 27 | version: Version, 28 | store: Store, 29 | ): Promise<void> { 30 | const versionPropvals = version.resource.getPropVals(); 31 | 32 | // Remove any prop that doesn't exist in the version 33 | for (const prop of resource.getPropVals().keys()) { 34 | if (!versionPropvals.has(prop)) { 35 | resource.removePropVal(prop); 36 | } 37 | } 38 | 39 | for (const [key, value] of versionPropvals.entries()) { 40 | await resource.set(key, value, store); 41 | } 42 | 43 | await resource.save(store); 44 | } 45 | 46 | export function groupVersionsByMonth( 47 | versions: Version[], 48 | ): Record<string, Version[]> { 49 | return versions.reduceRight((acc, version) => { 50 | const createdDate = new Date(version.commit.createdAt); 51 | const groupKey = groupFormatter.format(createdDate); 52 | const group = acc[groupKey] ?? []; 53 | 54 | return { 55 | ...acc, 56 | [groupKey]: [...group, version], 57 | }; 58 | }, {}); 59 | } 60 | 61 | function resourceToString(resource: Resource) { 62 | const obj = {}; 63 | 64 | for (const [key, value] of resource.getPropVals().entries()) { 65 | obj[key] = value; 66 | } 67 | 68 | return JSON.stringify(obj); 69 | } 70 | -------------------------------------------------------------------------------- /lib/src/store.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { jest } from '@jest/globals'; 3 | import { Resource, urls, Store } from './index.js'; 4 | 5 | describe('Store', () => { 6 | it('renders the populate value', async () => { 7 | const store = new Store(); 8 | const subject = 'https://atomicdata.dev/test'; 9 | const testval = 'Hi world'; 10 | const newResource = new Resource(subject); 11 | newResource.setUnsafe(urls.properties.description, testval); 12 | store.addResources(newResource); 13 | const gotResource = store.getResourceLoading(subject); 14 | const atomString = gotResource! 15 | .get(urls.properties.description)! 16 | .toString(); 17 | expect(atomString).to.equal(testval); 18 | }); 19 | 20 | it('fetches a resource', async () => { 21 | const store = new Store({ serverUrl: 'https://atomicdata.dev' }); 22 | const resource = await store.getResourceAsync( 23 | 'https://atomicdata.dev/properties/createdAt', 24 | ); 25 | 26 | if (resource.error) { 27 | throw resource.error; 28 | } 29 | 30 | const atomString = resource.get(urls.properties.shortname)!.toString(); 31 | expect(atomString).to.equal('created-at'); 32 | }); 33 | 34 | it('accepts a custom fetch implementation', async () => { 35 | const testResourceSubject = 'https://atomicdata.dev'; 36 | 37 | const customFetch = jest.fn( 38 | async (url: RequestInfo | URL, options: RequestInit | undefined) => { 39 | return fetch(url, options); 40 | }, 41 | ); 42 | 43 | const store = new Store(); 44 | 45 | await store.fetchResourceFromServer(testResourceSubject, { 46 | noWebSocket: true, 47 | }); 48 | 49 | expect(customFetch.mock.calls).to.have.length(0); 50 | 51 | store.injectFetch(customFetch); 52 | 53 | await store.fetchResourceFromServer(testResourceSubject, { 54 | noWebSocket: true, 55 | }); 56 | 57 | expect(customFetch.mock.calls).to.have.length(1); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /data-browser/src/components/Row.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as CSS from 'csstype'; 3 | import React from 'react'; 4 | import { ButtonDefault } from './Button'; 5 | 6 | export interface FlexProps extends React.HTMLAttributes<HTMLDivElement> { 7 | gap?: CSS.Property.Gap; 8 | justify?: CSS.Property.JustifyContent; 9 | direction?: CSS.Property.FlexDirection; 10 | center?: boolean; 11 | wrapItems?: boolean; 12 | fullWidth?: boolean; 13 | fullHeight?: boolean; 14 | as?: keyof JSX.IntrinsicElements; 15 | } 16 | 17 | export type RowProps = Omit<FlexProps, 'direction'> & { 18 | reverse?: boolean; 19 | }; 20 | export type ColumnProps = Omit<FlexProps, 'direction'> & { 21 | reverse?: boolean; 22 | }; 23 | 24 | export const Row = React.forwardRef< 25 | HTMLDivElement, 26 | React.PropsWithChildren<RowProps> 27 | >(({ children, reverse, ...props }, ref) => { 28 | return ( 29 | <Flex {...props} direction={reverse ? 'row-reverse' : 'row'} ref={ref}> 30 | {children} 31 | </Flex> 32 | ); 33 | }); 34 | 35 | Row.displayName = 'Row'; 36 | 37 | export const Column = React.forwardRef< 38 | HTMLDivElement, 39 | React.PropsWithChildren<ColumnProps> 40 | >(({ children, reverse, ...props }, ref) => { 41 | return ( 42 | <Flex 43 | {...props} 44 | direction={reverse ? 'column-reverse' : 'column'} 45 | ref={ref} 46 | > 47 | {children} 48 | </Flex> 49 | ); 50 | }); 51 | 52 | Column.displayName = 'Column'; 53 | 54 | const Flex = styled.div<FlexProps>` 55 | align-items: ${p => (p.center ? 'center' : 'initial')}; 56 | display: flex; 57 | gap: ${p => p.gap ?? `${p.theme.margin}rem`}; 58 | justify-content: ${p => p.justify ?? 'start'}; 59 | flex-direction: ${p => p.direction ?? 'row'}; 60 | flex-wrap: ${p => (p.wrapItems ? 'wrap' : 'no-wrap')}; 61 | width: ${p => (p.fullWidth ? '100%' : 'initial')}; 62 | height: ${p => (p.fullHeight ? '100%' : 'initial')}; 63 | 64 | & ${ButtonDefault} { 65 | align-self: flex-start; 66 | } 67 | `; 68 | -------------------------------------------------------------------------------- /data-browser/src/components/NavStyleButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useSettings } from '../helpers/AppSettings'; 4 | 5 | interface NavBarButtonProps { 6 | top: boolean; 7 | floating: boolean; 8 | title: string; 9 | } 10 | 11 | /** Button used for indicating where the navbar will be placed */ 12 | export function NavStyleButton({ 13 | top, 14 | floating, 15 | title, 16 | }: NavBarButtonProps): JSX.Element { 17 | const { navbarTop, setNavbarTop, navbarFloating, setNavbarFloating } = 18 | useSettings(); 19 | 20 | return ( 21 | <NavStyleButtonStyling 22 | title={title} 23 | current={navbarTop === top && navbarFloating === floating} 24 | onClick={() => { 25 | setNavbarTop(top); 26 | setNavbarFloating(floating); 27 | }} 28 | > 29 | <svg 30 | width='80' 31 | height='80' 32 | viewBox='0 0 80 80' 33 | fill='none' 34 | xmlns='http://www.w3.org/2000/svg' 35 | > 36 | {floating ? ( 37 | <rect x='10' y='60' width='60' height='10' rx='5' /> 38 | ) : ( 39 | <rect x='0' y={top ? '0' : '70'} width='80' height='10' /> 40 | )} 41 | </svg> 42 | </NavStyleButtonStyling> 43 | ); 44 | } 45 | 46 | interface NavStyleButtonStylingProps { 47 | current: boolean; 48 | } 49 | 50 | const NavStyleButtonStyling = styled.button<NavStyleButtonStylingProps>` 51 | rect { 52 | fill: ${p => (p.current ? p.theme.colors.main : p.theme.colors.bg2)}; 53 | } 54 | &:hover { 55 | border-color: ${p => p.theme.colors.mainLight}; 56 | } 57 | &:active { 58 | border-color: ${p => p.theme.colors.mainDark}; 59 | } 60 | background-color: ${props => props.theme.colors.bg}; 61 | cursor: pointer; 62 | border: solid 1px 63 | ${p => (p.current ? p.theme.colors.mainLight : p.theme.colors.bg2)}; 64 | border-radius: ${props => props.theme.radius}; 65 | padding: 0; 66 | overflow: hidden; 67 | line-height: 0; 68 | `; 69 | -------------------------------------------------------------------------------- /data-browser/src/components/SearchFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { urls, useArray, useProperty, useResource } from '@tomic/react'; 3 | import { ResourceSelector } from '../components/forms/ResourceSelector'; 4 | 5 | /** 6 | * Shows a Class selector to the user. 7 | * If a Class is selected, the filters for the required and recommended properties 8 | * of that Class are shown. 9 | */ 10 | export function ClassFilter({ filters, setFilters }): JSX.Element { 11 | const [klass, setClass] = useState<string | undefined>(undefined); 12 | const resource = useResource(klass); 13 | const [requiredProps] = useArray(resource, urls.properties.requires); 14 | const [recommendedProps] = useArray(resource, urls.properties.recommends); 15 | const allProps = [...requiredProps, ...recommendedProps]; 16 | 17 | useEffect(() => { 18 | // Set the filters to the default values of the properties 19 | setFilters({ 20 | ...filters, 21 | [urls.properties.isA]: klass, 22 | }); 23 | }, [klass, JSON.stringify(filters)]); 24 | 25 | return ( 26 | <div> 27 | <ResourceSelector 28 | setSubject={setClass} 29 | value={klass} 30 | classType={urls.classes.class} 31 | /> 32 | {allProps?.map(propertySubject => ( 33 | <PropertyFilter 34 | key={propertySubject} 35 | subject={propertySubject} 36 | filters={filters} 37 | setFilters={setFilters} 38 | /> 39 | ))} 40 | </div> 41 | ); 42 | } 43 | 44 | function PropertyFilter({ filters, setFilters, subject }): JSX.Element { 45 | const prop = useProperty(subject); 46 | 47 | function handleChange(e) { 48 | setFilters({ 49 | ...filters, 50 | [prop.shortname]: e.target.value, 51 | }); 52 | } 53 | 54 | return ( 55 | <div> 56 | <label>{prop.shortname}</label> 57 | <input 58 | type='text' 59 | value={filters[prop.shortname]} 60 | onChange={handleChange} 61 | /> 62 | </div> 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /data-browser/src/routes/EditRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useNavigate } from 'react-router'; 3 | import { useResource } from '@tomic/react'; 4 | import { newURL } from '../helpers/navigation'; 5 | import { ContainerNarrow } from '../components/Containers'; 6 | import { InputStyled } from '../components/forms/InputStyles'; 7 | import { ResourceForm } from '../components/forms/ResourceForm'; 8 | import { useCurrentSubject } from '../helpers/useCurrentSubject'; 9 | import { ClassDetail } from '../components/ClassDetail'; 10 | import { Title } from '../components/Title'; 11 | import Parent from '../components/Parent'; 12 | 13 | /** Form for instantiating a new Resource from some Class */ 14 | export function Edit(): JSX.Element { 15 | const [subject] = useCurrentSubject(); 16 | const resource = useResource(subject); 17 | const [subjectInput, setSubjectInput] = useState<string | undefined>( 18 | undefined, 19 | ); 20 | const navigate = useNavigate(); 21 | 22 | function handleClassSet(e) { 23 | e.preventDefault(); 24 | 25 | if (!subjectInput) { 26 | throw new Error('No subject input'); 27 | } 28 | 29 | navigate(newURL(subjectInput)); 30 | } 31 | 32 | return ( 33 | <> 34 | <Parent resource={resource} /> 35 | <ContainerNarrow> 36 | {subject ? ( 37 | <> 38 | <Title resource={resource} prefix='Edit' /> 39 | <ClassDetail resource={resource} /> 40 | {/* Key is required for re-rendering when subject changes */} 41 | <ResourceForm resource={resource} key={subject} /> 42 | </> 43 | ) : ( 44 | <form onSubmit={handleClassSet}> 45 | <h1>edit a resource</h1> 46 | <InputStyled 47 | value={subjectInput || undefined} 48 | onChange={e => setSubjectInput(e.target.value)} 49 | placeholder={'Enter a Resource URL...'} 50 | /> 51 | </form> 52 | )} 53 | </ContainerNarrow> 54 | </> 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /react/src/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | export type SetLocalStorageValue<T> = (value: T | ((val: T) => T)) => void; 4 | /** 5 | * Hook for storing information to LocalStorage. Note that if you use this same 6 | * hook in multiple component instances, these will *not* share state! If you 7 | * want that behavior, you should use this hook inside a Context object. 8 | */ 9 | export function useLocalStorage<T>( 10 | key: string, 11 | initialValue: T, 12 | ): [T, SetLocalStorageValue<T>] { 13 | // State to store our value 14 | // Pass initial state function to useState so logic is only executed once 15 | const [storedValue, setStoredValue] = useState<T>(() => { 16 | try { 17 | // Get from local storage by key 18 | const item = window.localStorage.getItem(key); 19 | 20 | if (item === 'undefined') { 21 | return initialValue; 22 | } 23 | 24 | // Parse stored json or if none return initialValue 25 | return item ? JSON.parse(item) : initialValue; 26 | } catch (error) { 27 | // If error also return initialValue 28 | console.error(`Error finding ${key} in localStorage:`, error); 29 | 30 | return initialValue; 31 | } 32 | }); 33 | 34 | // Return a wrapped version of useState's setter function that 35 | // persists the new value to localStorage. 36 | const setValue = useCallback( 37 | (value: T | ((val: T) => T)) => { 38 | try { 39 | // Allow value to be a function so we have same API as useState 40 | const valueToStore = 41 | value instanceof Function ? value(storedValue) : value; 42 | // Save state 43 | setStoredValue(valueToStore); 44 | // Save to local storage 45 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 46 | } catch (error) { 47 | // A more advanced implementation would handle the error case 48 | console.error(error); 49 | } 50 | }, 51 | [storedValue, key], 52 | ); 53 | 54 | return [storedValue, setValue]; 55 | } 56 | -------------------------------------------------------------------------------- /react/README.md: -------------------------------------------------------------------------------- 1 | # @tomic/react: The Atomic Data library for React 2 | 3 | A library for viewing and creating Atomic Data. 4 | Re-exports `@tomic/lib`. 5 | 6 | [**demo + template on codesandbox**!](https://codesandbox.io/s/atomic-data-react-template-4y9qu?file=/src/MyResource.tsx:0-1223) 7 | 8 | [**docs**](https://atomicdata-dev.github.io/atomic-data-browser/docs/modules/_tomic_react.html) 9 | 10 | ## Setup 11 | 12 | When initializing your App, initialize the store, which will contain all data. 13 | Wrap your App in a `StoreContext.Provider`, and pass the newly initialized store to it. 14 | 15 | ```ts 16 | // App.tsx 17 | import { StoreContext, Store } from "@tomic/react"; 18 | import { MyResource } from "./MyResource"; 19 | 20 | // The store contains all the data for 21 | const store = new Store(); 22 | 23 | export default function App() { 24 | return ( 25 | <StoreContext.Provider value={store}> 26 | <MyResource subject={subject} /> 27 | </StoreContext.Provider> 28 | ); 29 | } 30 | ``` 31 | 32 | Now, your Store can be accessed in React's context, which you can use the `atomic-react` hooks! 33 | 34 | ## Hooks 35 | 36 | ### useResource, useString, useTitle 37 | 38 | ```ts 39 | // Get the Resouce, and all its properties 40 | const resource = useResource('https://atomicdata.dev/classes/Agent'); 41 | // The title takes either the Title, the Shortname or the URL of the resource 42 | const title = useTitle(resource); 43 | // All useValue / useString / useArray / useBoolean hooks have a getter and a setter. 44 | // Use the setter in forms. 45 | const [description, setDescription] = useString(resource, 'https://atomicdata.dev/properties/description'); 46 | // The current Agent is the signed in user, inluding their private key. This enables you to create Commits and update data on a server. 47 | const [agent, setAgent] = useCurrentAgent(); 48 | 49 | return ( 50 | <> 51 | <h1>{title}</h2> 52 | <textarea value={description} onChange={e => setDescription(e.target.value)} /> 53 | <button type={button} onClick={resource.save}>Save & commit</button> 54 | </> 55 | ) 56 | 57 | ``` 58 | -------------------------------------------------------------------------------- /data-browser/src/components/forms/UploadForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | import { Resource } from '@tomic/react'; 3 | import { useDropzone } from 'react-dropzone'; 4 | import { Button } from '../Button'; 5 | import FilePill from '../FilePill'; 6 | import { ErrMessage } from './InputStyles'; 7 | import { useUpload } from '../../hooks/useUpload'; 8 | 9 | interface UploadFormProps { 10 | /** 11 | * The resource which the newly uploaded files will refer to as parent. In 12 | * other words, the newly uploaded files will be children of this resource. 13 | */ 14 | parentResource: Resource; 15 | } 16 | 17 | /** Shows a Button + drag and drop interface for uploading files */ 18 | export default function UploadForm({ 19 | parentResource, 20 | }: UploadFormProps): JSX.Element { 21 | const [uploadedFiles, setUploadedFiles] = useState<string[]>([]); 22 | const { upload, isUploading, error } = useUpload(parentResource); 23 | 24 | const onDrop = useCallback( 25 | async (files: File[]) => { 26 | const result = await upload(files); 27 | 28 | setUploadedFiles(result); 29 | }, 30 | [upload], 31 | ); 32 | 33 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); 34 | 35 | if (parentResource.new) { 36 | return <p>You can add attachments after saving the resource.</p>; 37 | } 38 | 39 | return ( 40 | <div> 41 | <div {...getRootProps()}> 42 | <input {...getInputProps()} /> 43 | {isDragActive ? ( 44 | <p>{'Drop the files here ...'}</p> 45 | ) : ( 46 | <Button 47 | subtle 48 | onClick={() => null} 49 | loading={isUploading ? 'Uploading...' : undefined} 50 | > 51 | Upload file(s)... 52 | </Button> 53 | )} 54 | {error && <ErrMessage>{error.message}</ErrMessage>} 55 | </div> 56 | {uploadedFiles.length > 0 && 57 | uploadedFiles.map(fileSubject => ( 58 | <FilePill key={fileSubject} subject={fileSubject} /> 59 | ))} 60 | </div> 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /data-browser/src/routes/History/HistoryDesktopView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HistoryViewProps } from './HistoryViewProps'; 3 | import styled from 'styled-components'; 4 | import { Button } from '../../components/Button'; 5 | import { Card } from '../../components/Card'; 6 | import { Column } from '../../components/Row'; 7 | import { Title } from '../../components/Title'; 8 | import { ResourceCardDefault } from '../../views/Card/ResourceCard'; 9 | import { VersionTitle } from './VersionTitle'; 10 | import { VersionScroller } from './VersionScroller'; 11 | 12 | export function HistoryDesktopView({ 13 | resource, 14 | groupedVersions, 15 | selectedVersion, 16 | isCurrentVersion, 17 | onNextVersion, 18 | onPreviousVersion, 19 | onSelectVersion, 20 | onVersionAccept, 21 | }: HistoryViewProps) { 22 | return ( 23 | <> 24 | <CurrentItem> 25 | <Column fullHeight> 26 | <Title resource={resource} prefix='History of' link /> 27 | {selectedVersion && selectedVersion?.resource && ( 28 | <> 29 | <VersionTitle version={selectedVersion} /> 30 | <StyledCard> 31 | <ResourceCardDefault resource={selectedVersion.resource} /> 32 | </StyledCard> 33 | <Button onClick={onVersionAccept} disabled={isCurrentVersion}> 34 | Make current version 35 | </Button> 36 | </> 37 | )} 38 | </Column> 39 | </CurrentItem> 40 | <VersionScroller 41 | persistSelection 42 | subject={resource.getSubject()} 43 | groupedVersions={groupedVersions} 44 | onNextItem={onPreviousVersion} 45 | onPreviousItem={onNextVersion} 46 | selectedVersion={selectedVersion} 47 | onSelectVersion={onSelectVersion} 48 | title='Versions' 49 | /> 50 | </> 51 | ); 52 | } 53 | 54 | const StyledCard = styled(Card)` 55 | flex: 1; 56 | overflow: auto; 57 | width: 100%; 58 | `; 59 | 60 | const CurrentItem = styled.div` 61 | flex: 1; 62 | 63 | & h1 { 64 | margin-bottom: 0; 65 | } 66 | `; 67 | -------------------------------------------------------------------------------- /react/src/useServerSearch.tsx: -------------------------------------------------------------------------------- 1 | import { buildSearchSubject, SearchOpts, urls } from '@tomic/lib'; 2 | import { useEffect, useMemo, useState } from 'react'; 3 | import { useArray, useDebounce, useResource, useServerURL } from './index.js'; 4 | 5 | interface SearchResults { 6 | /** Subject URLs for resources that match the query */ 7 | results: string[]; 8 | loading: boolean; 9 | error?: Error; 10 | } 11 | 12 | const emptyArray = []; 13 | 14 | interface SearchOptsHook extends SearchOpts { 15 | /** 16 | * Debouncing makes queries slower, but prevents sending many request. Number 17 | * respresents milliseconds. 18 | */ 19 | debounce?: number; 20 | } 21 | 22 | /** Pass a query to search the current server */ 23 | export function useServerSearch( 24 | query: string | undefined, 25 | opts: SearchOptsHook = {}, 26 | ): SearchResults { 27 | const { debounce = 50 } = opts; 28 | 29 | const [results, setResults] = useState<string[]>([]); 30 | const [serverURL] = useServerURL(); 31 | // Calculating the query takes a while, so we debounce it 32 | const debouncedQuery = useDebounce(query, debounce) ?? ''; 33 | 34 | const searchSubjectURL: string = useMemo( 35 | () => buildSearchSubject(serverURL, debouncedQuery, opts), 36 | [debouncedQuery, opts, serverURL], 37 | ); 38 | 39 | const resource = useResource(searchSubjectURL, { 40 | noWebSocket: true, 41 | }); 42 | 43 | const [resultsIn] = useArray(resource, urls.properties.endpoint.results); 44 | 45 | // Only set new results if the resource is no longer loading, which improves UX 46 | useEffect(() => { 47 | if (!resource.loading && resultsIn) { 48 | setResults(resultsIn as string[]); 49 | } 50 | }, [ 51 | // Prevent re-rendering if the resultsIn is the same 52 | resultsIn?.toString(), 53 | resource.loading, 54 | ]); 55 | 56 | if (!query) { 57 | return { 58 | results: emptyArray, 59 | loading: false, 60 | error: undefined, 61 | }; 62 | } 63 | 64 | // Return the width so we can use it in our components 65 | return { results, loading: resource.loading, error: resource.error }; 66 | } 67 | -------------------------------------------------------------------------------- /data-browser/src/views/Card/CollectionCard.tsx: -------------------------------------------------------------------------------- 1 | import { useArray, useString, useTitle, properties } from '@tomic/react'; 2 | import React, { useState } from 'react'; 3 | 4 | import Markdown from '../../components/datatypes/Markdown'; 5 | import { AtomicLink } from '../../components/AtomicLink'; 6 | import { CardInsideFull, CardRow } from '../../components/Card'; 7 | import { ResourceInline } from '../ResourceInline'; 8 | import { CardViewProps } from './CardViewProps'; 9 | import { Button } from '../../components/Button'; 10 | 11 | const MAX_COUNT = 5; 12 | 13 | /** 14 | * Renders a Resource and all its Properties in a random order. Title 15 | * (shortname) is rendered prominently at the top. 16 | */ 17 | function CollectionCard({ resource, small }: CardViewProps): JSX.Element { 18 | const [title] = useTitle(resource); 19 | const [description] = useString(resource, properties.description); 20 | const [members] = useArray(resource, properties.collection.members); 21 | const [showAll, setShowMore] = useState(false); 22 | 23 | const tooMany = members.length > MAX_COUNT; 24 | let subjects = members; 25 | 26 | if (!showAll && tooMany) { 27 | subjects = subjects.slice(0, MAX_COUNT); 28 | } 29 | 30 | return ( 31 | <React.Fragment> 32 | <AtomicLink subject={resource.getSubject()}> 33 | <h2>{title}</h2> 34 | </AtomicLink> 35 | {description && <Markdown text={description} />} 36 | {!small && ( 37 | <CardInsideFull> 38 | {subjects.map(member => { 39 | return ( 40 | <CardRow key={member}> 41 | <ResourceInline subject={member} /> 42 | </CardRow> 43 | ); 44 | })} 45 | {tooMany && ( 46 | <CardRow> 47 | <Button clean onClick={() => setShowMore(!showAll)}> 48 | {showAll 49 | ? 'show less' 50 | : `show ${members.length - MAX_COUNT} more`} 51 | </Button> 52 | </CardRow> 53 | )} 54 | </CardInsideFull> 55 | )} 56 | </React.Fragment> 57 | ); 58 | } 59 | 60 | export default CollectionCard; 61 | --------------------------------------------------------------------------------