├── .gitignore ├── backend ├── .env.example ├── server.js ├── src │ ├── env.js │ ├── index.js │ └── db.js ├── .gitignore └── package.json ├── frontend ├── .env ├── .prettierignore ├── .eslintignore ├── .env.development ├── src │ ├── contexts │ │ └── user.js │ ├── images │ │ ├── not-found.png │ │ ├── snowflakes.gif │ │ ├── snowflakes.webp │ │ ├── transparent.png │ │ ├── integrations │ │ │ └── surfer.png │ │ └── android-chrome-192x192.png │ ├── components │ │ ├── MenuItem │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Checkbox │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Santa │ │ │ ├── santa.png │ │ │ ├── index.js │ │ │ └── index.module.scss │ │ ├── Skeleton │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── CheckboxLabel │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── FlatIcon │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Card │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Tab │ │ │ ├── index.js │ │ │ └── index.module.scss │ │ ├── Loading │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── RadioGroup │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Tooltip │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── SnackbarProvider │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── Launch │ │ │ ├── index.js │ │ │ └── index.module.scss │ │ ├── Link │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Typography │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Image │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Tabs │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── ErrorMessageBody │ │ │ └── index.js │ │ ├── IntegrationTemplate │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Switch │ │ │ ├── index.js │ │ │ └── index.module.scss │ │ ├── BottomPanel │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── WhiteHole │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── AvailableSpace │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Dialog │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── IconButton │ │ │ ├── index.js │ │ │ └── index.module.scss │ │ ├── Select │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── DoubleClick │ │ │ └── index.js │ │ ├── SnackbarMessage │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── DroppedFile │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── ExternalFile │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── Button │ │ │ ├── index.js │ │ │ └── index.module.scss │ │ ├── TextField │ │ │ ├── index.js │ │ │ └── index.module.scss │ │ ├── Photo │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ ├── FileUploadField │ │ │ ├── index.module.scss │ │ │ └── index.js │ │ └── SurferSearch │ │ │ ├── index.module.scss │ │ │ └── index.js │ ├── setupTests.js │ ├── hooks │ │ ├── useEffectOnce.js │ │ ├── useDialog.js │ │ └── useURLParams.js │ ├── reportWebVitals.js │ ├── api │ │ ├── request.js │ │ ├── useApi.js │ │ └── endpoints.json │ ├── styles │ │ ├── globals.scss │ │ └── variables.scss │ ├── functions │ │ └── utils.js │ ├── index.js │ ├── registerServiceWorker.js │ └── App │ │ ├── index.module.scss │ │ └── index.js ├── public │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── manifest.json │ ├── index.html │ └── safari-pinned-tab.svg ├── .prettierrc ├── .gitignore ├── .eslintrc.json ├── package.json └── README.md ├── icon.png ├── icon.psd ├── preview.png ├── Spacefile ├── README.md └── Discovery.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.space 2 | /.idea 3 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | DETA_PROJECT_KEY= 2 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_BASE_URL=/api -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /.idea -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_BASE_URL=http://localhost:8080 -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/icon.png -------------------------------------------------------------------------------- /icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/icon.psd -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/preview.png -------------------------------------------------------------------------------- /frontend/src/contexts/user.js: -------------------------------------------------------------------------------- 1 | import {createContext} from "react" 2 | 3 | export default createContext() 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/src/images/not-found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/src/images/not-found.png -------------------------------------------------------------------------------- /frontend/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/public/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/src/images/snowflakes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/src/images/snowflakes.gif -------------------------------------------------------------------------------- /frontend/src/images/snowflakes.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/src/images/snowflakes.webp -------------------------------------------------------------------------------- /frontend/src/images/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/src/images/transparent.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/src/components/MenuItem/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | font-size: 14px; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/components/Checkbox/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .disabled { 4 | opacity: 0.6; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/components/Santa/santa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/src/components/Santa/santa.png -------------------------------------------------------------------------------- /frontend/src/components/Skeleton/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | border-radius: 8px; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/src/images/integrations/surfer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/src/images/integrations/surfer.png -------------------------------------------------------------------------------- /frontend/src/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikhailsdv/deta-black-hole/HEAD/frontend/src/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/src/components/CheckboxLabel/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | //position: relative; 5 | user-select: none; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/components/FlatIcon/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | width: 24px; 5 | height: 24px; 6 | font-size: 24px; 7 | } 8 | -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | const expressApp = require("./src/index") 2 | const port = 8080 3 | 4 | expressApp.listen(port, () => { 5 | console.log(`Example app listening on port ${port}`) 6 | }) -------------------------------------------------------------------------------- /backend/src/env.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | require("dotenv").config({ 3 | path: path.resolve(__dirname, "../.env"), 4 | override: false, 5 | }); 6 | 7 | module.exports = process.env; 8 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.idea 4 | 5 | # misc 6 | .DS_Store 7 | .env 8 | .env.local 9 | .env.development.local 10 | .env.test.local 11 | .env.production.local 12 | -------------------------------------------------------------------------------- /frontend/src/components/Card/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | overflow: hidden; 6 | box-shadow: none; 7 | padding: 20px; 8 | background-color: $paper-color; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/components/Tab/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Tab from "@mui/material/Tab" 3 | import styles from "./index.module.scss" 4 | 5 | const Tab_ = props => { 6 | return 7 | } 8 | 9 | export default Tab_ 10 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | .progress { 5 | position: fixed; 6 | right: 0; 7 | bottom: 0; 8 | left: 0; 9 | top: 0; 10 | margin: auto; 11 | color: lightgray; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/components/RadioGroup/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | width: 100%; 6 | 7 | .row { 8 | flex-direction: row; 9 | 10 | .mr { 11 | margin-right: 32px; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "useTabs": true, 5 | "semi": false, 6 | "singleQuote": false, 7 | "jsxSingleQuote": false, 8 | "bracketSpacing": false, 9 | "jsxBracketSameLine": false, 10 | "arrowParens": "avoid" 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/hooks/useEffectOnce.js: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from "react" 2 | 3 | export default function useEffectOnce(fn, deps) { 4 | const disabled = useRef(false) 5 | 6 | useEffect(() => { 7 | if (!disabled.current) { 8 | disabled.current = true 9 | return fn() 10 | } 11 | }, [fn, deps]) 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/Tooltip/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | background-color: white; 5 | color: #6c748c; 6 | font-size: 14px; 7 | font-weight: 400; 8 | box-shadow: 0 4px 10px #0000001f; 9 | padding: 8px 12px; 10 | text-align: center; 11 | } 12 | .arrow { 13 | color: white; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/SnackbarProvider/index.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | /*#root 4 | > div[class*="makeStyles-top"][class*="makeStyles-right"][class*="makeStyles-root"], 5 | #root > div[class^="jss"][class*=" jss"] { 6 | padding-top: 0; 7 | @media screen and (max-width: $md) { 8 | padding-top: 65px; 9 | } 10 | }*/ 11 | -------------------------------------------------------------------------------- /frontend/src/components/Launch/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import styles from "./index.module.scss" 4 | 5 | const Launch = props => { 6 | return ( 7 |
8 | {/**/} 9 | loading 10 |
11 | ) 12 | } 13 | 14 | export default Launch 15 | -------------------------------------------------------------------------------- /frontend/src/components/Link/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | font-size: inherit; 5 | color: $accent-color; 6 | text-decoration: none; 7 | 8 | &.underline-always { 9 | text-decoration: underline; 10 | } 11 | &.underline-hover { 12 | &:hover { 13 | text-decoration: underline; 14 | } 15 | } 16 | 17 | &.block { 18 | color: unset; 19 | display: block; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/Tab/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | padding: 0 10px; 5 | margin-right: 10px; 6 | text-transform: none; 7 | min-width: unset; 8 | max-width: unset; 9 | // height: 44px; 10 | } 11 | .textColorPrimary { 12 | color: $emphasis-medium; 13 | font-size: 16px; 14 | font-weight: 400; 15 | } 16 | .selected { 17 | color: $emphasis-high; 18 | font-weight: 500; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/Typography/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | &.high { 5 | color: $emphasis-high; 6 | } 7 | 8 | &.medium { 9 | color: $emphasis-medium; 10 | } 11 | 12 | &.outlined { 13 | color: $emphasis-outlined; 14 | } 15 | 16 | & > svg { 17 | position: relative; 18 | width: 1em; 19 | height: 1em; 20 | vertical-align: baseline; 21 | top: 4px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | /.idea 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /frontend/src/components/Image/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | border-radius: 8px; 6 | user-select: none; 7 | object-fit: cover; 8 | object-position: center; 9 | background-color: $block-placeholder-color; 10 | &.loaded { 11 | background-color: transparent; 12 | } 13 | &.transparent { 14 | background-image: url("../../images/transparent.png"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/Launch/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: fixed; 5 | background-color: $background-color; 6 | width: 100%; 7 | height: 100%; 8 | z-index: 99999; 9 | top: 0; 10 | left: 0; 11 | 12 | .icon { 13 | position: absolute; 14 | width: 62px; 15 | height: 62px; 16 | top: 0; 17 | left: 0; 18 | bottom: 0; 19 | right: 0; 20 | margin: auto; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app"], 3 | "rules": { 4 | "import/no-anonymous-default-export": "off", 5 | "@next/next/no-img-element": "off", 6 | "no-irregular-whitespace": "off", 7 | "prefer-const": "error", 8 | "no-mixed-spaces-and-tabs": "off", 9 | "no-extra-semi": "off" 10 | }, 11 | "env": { 12 | "es6": true, 13 | "browser": true, 14 | "commonjs": true, 15 | "node": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/Tabs/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | // border-bottom: solid 1px #D6DAE0; 5 | @media screen and (max-width: $md) { 6 | width: 100%; 7 | } 8 | } 9 | 10 | .scroller { 11 | display: flex; 12 | align-items: center; 13 | height: 100%; 14 | } 15 | 16 | .flexContainer { 17 | height: 100%; 18 | } 19 | 20 | .indicator { 21 | background-color: $accent-color; 22 | height: 3px; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/api/request.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import axiosRetry from "axios-retry" 3 | 4 | const request = axios.create({ 5 | baseURL: process.env.REACT_APP_API_BASE_URL, 6 | timeout: 15000, 7 | /*headers: { 8 | "Access-Control-Allow-Origin": "*", 9 | },*/ 10 | }) 11 | 12 | axiosRetry(request, { 13 | retries: 3, 14 | /*retryCondition: error => { 15 | return error.response.status === 503 16 | },*/ 17 | }) 18 | 19 | export default request 20 | -------------------------------------------------------------------------------- /frontend/src/components/Card/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Card from "@mui/material/Card" 5 | 6 | import styles from "./index.module.scss" 7 | 8 | const Card_ = props => { 9 | const {className, classes = {}, ...rest} = props 10 | 11 | return ( 12 | 16 | ) 17 | } 18 | 19 | export default Card_ 20 | -------------------------------------------------------------------------------- /frontend/src/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | //@tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | html, 7 | body { 8 | width: 100%; 9 | //height: 100%; 10 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 11 | background-color: $background-color; 12 | font-size: 16px; 13 | font-family: $font-family; 14 | margin: 0; 15 | } 16 | 17 | #root { 18 | position: relative; 19 | min-height: 100vh; 20 | //overflow-x: hidden; 21 | } -------------------------------------------------------------------------------- /frontend/src/components/ErrorMessageBody/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | const ErrorMessageBody = props => { 4 | const {message, errors} = props 5 | 6 | const errorCode = JSON.stringify({ 7 | section: window.location.pathname, 8 | ...errors, 9 | }) 10 | 11 | return ( 12 | <> 13 | {message} 14 |
15 |
16 | {errorCode} 17 | 18 | ) 19 | } 20 | 21 | export default ErrorMessageBody 22 | -------------------------------------------------------------------------------- /frontend/src/components/IntegrationTemplate/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | padding: 8px 12px 8px 8px; 6 | border-radius: 12px; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | gap: 12px; 11 | cursor: pointer; 12 | &.selected { 13 | box-shadow: inset 0 0 0 3px $accent-color; 14 | } 15 | 16 | .image { 17 | width: 24px; 18 | height: 24px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/components/Tabs/index.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from "react" 2 | import Tabs from "@mui/material/Tabs" 3 | import styles from "./index.module.scss" 4 | 5 | const Tabs_ = props => { 6 | const [key, setKey] = useState(0) 7 | 8 | useEffect(() => { 9 | window.addEventListener("load", () => { 10 | setKey(1) 11 | }) 12 | }, []) 13 | 14 | return 15 | } 16 | 17 | export default Tabs_ 18 | -------------------------------------------------------------------------------- /frontend/src/components/Typography/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Typography from "@mui/material/Typography" 5 | 6 | import styles from "./index.module.scss" 7 | 8 | export default function _Typography(props) { 9 | const {emphasis = "high", className, ...rest} = props 10 | 11 | return ( 12 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "axios": "^1.1.3", 13 | "deta": "^1.1.0", 14 | "dotenv": "^16.0.3", 15 | "express": "^4.18.2", 16 | "express-fileupload": "^1.4.0", 17 | "mime": "^3.0.0", 18 | "sharp": "^0.31.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Black Hole", 3 | "name": "Deta Black Hole", 4 | "icons": [ 5 | { 6 | "src": "android-chrome-192x192.png", 7 | "type": "image/png", 8 | "sizes": "192x192" 9 | }, 10 | { 11 | "src": "android-chrome-512x512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "background_color": "#f3f7fa", 19 | "description": "A black hole for your photos" 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/components/MenuItem/index.js: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from "react" 2 | import classnames from "classnames" 3 | 4 | import MenuItem from "@mui/material/MenuItem" 5 | 6 | import styles from "./index.module.scss" 7 | 8 | const MenuItem_ = forwardRef((props, ref) => { 9 | const {className, classes = {}, ...rest} = props 10 | 11 | return ( 12 | 17 | ) 18 | }) 19 | 20 | export default MenuItem_ 21 | -------------------------------------------------------------------------------- /frontend/src/components/Skeleton/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Skeleton from "@mui/material/Skeleton" 5 | 6 | import styles from "./index.module.scss" 7 | 8 | const Skeleton_ = props => { 9 | const {className, classes = {}, ...rest} = props 10 | 11 | return ( 12 | 18 | ) 19 | } 20 | 21 | export default Skeleton_ 22 | -------------------------------------------------------------------------------- /frontend/src/components/FlatIcon/index.js: -------------------------------------------------------------------------------- 1 | import React, {useRef} from "react" 2 | import classnames from "classnames" 3 | import styles from "./index.module.scss" 4 | 5 | const FlatIcon = props => { 6 | const {name, className, ...rest} = props 7 | const root = useRef(null) 8 | 9 | return ( 10 | 15 | ) 16 | } 17 | 18 | const createFlatIcon = name => props => 19 | 20 | export {FlatIcon, createFlatIcon} 21 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDialog.js: -------------------------------------------------------------------------------- 1 | import {useState, useCallback, useMemo} from "react" 2 | 3 | import Dialog from "../components/Dialog" 4 | 5 | export default function useDialog() { 6 | const [isOpen, setIsOpen] = useState(false) 7 | 8 | const open = useCallback(() => { 9 | setIsOpen(true) 10 | }, []) 11 | 12 | const close = useCallback(() => { 13 | setIsOpen(false) 14 | }, []) 15 | 16 | const props = useMemo( 17 | () => ({ 18 | open: isOpen, 19 | onClose: close, 20 | }), 21 | [isOpen, close] 22 | ) 23 | 24 | return {open, close, props, Component: Dialog} 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/Tooltip/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import Tooltip from "@mui/material/Tooltip" 4 | 5 | import styles from "./index.module.scss" 6 | 7 | const Tooltip_ = props => { 8 | return props.title ? ( 9 | 22 | ) : ( 23 | props.children 24 | ) 25 | } 26 | 27 | export default Tooltip_ 28 | -------------------------------------------------------------------------------- /Spacefile: -------------------------------------------------------------------------------- 1 | # Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 2 | v: 0 3 | icon: ./icon.png 4 | micros: 5 | - name: black-hole-frontend 6 | src: ./frontend/ 7 | engine: static 8 | public_routes: 9 | - "/wh/public/*" 10 | - "/static/*" 11 | - "/api/photo/*" 12 | - "/api/white-hole/public/*" 13 | - "/api/integration/*" 14 | primary: true 15 | commands: 16 | - npm run build 17 | serve: build/ 18 | 19 | - name: black-hole-backend 20 | src: ./backend/ 21 | path: api 22 | engine: nodejs16 23 | presets: 24 | api_keys: true 25 | run: "node server.js" 26 | -------------------------------------------------------------------------------- /frontend/src/components/Switch/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import classnames from "classnames" 4 | 5 | import Switch from "@mui/material/Switch" 6 | 7 | import styles from "./index.module.scss" 8 | 9 | const Switch_ = props => { 10 | const {className, classes = {}, ...rest} = props 11 | 12 | return ( 13 | 23 | ) 24 | } 25 | 26 | export default Switch_ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deta Black Hole 2 | 3 | Your personal image hosting based on [Deta Space](https://deta.space/) 🚀 4 | 5 | ![image](./preview.png) 6 | 7 | ## Latest release 8 | 9 | [](https://alpha.deta.space/discovery/@mikhailsdv/black_hole-3kf) 10 | 11 | ## Feedback 12 | 13 | If something goes wrong please [open an issues](https://github.com/mikhailsdv/deta-black-hole/issues/new). You can also PM me on Telegram [@mikhailsdv](https://t.me/mikhailsdv). 14 | -------------------------------------------------------------------------------- /frontend/src/components/BottomPanel/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: fixed; 5 | bottom: 0; 6 | z-index: 8; 7 | width: 100%; 8 | transform: translateY(100%); 9 | transition: $transition; 10 | &.visible { 11 | transform: translateY(0); 12 | } 13 | 14 | .container { 15 | position: relative; 16 | 17 | .panel { 18 | position: relative; 19 | padding: 8px; 20 | width: 100%; 21 | border-top-right-radius: 12px; 22 | border-top-left-radius: 12px; 23 | background-color: $block-placeholder-color; 24 | box-shadow: 0 -1px 5px -1px rgba(0, 0, 0, .4); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/hooks/useURLParams.js: -------------------------------------------------------------------------------- 1 | import {useMemo} from "react" 2 | import {useLocation} from "react-router-dom" 3 | 4 | export default function useURLParams({parseNumeric = false} = {}) { 5 | const location = useLocation() 6 | 7 | const params = useMemo(() => { 8 | const result = {} 9 | const urlParams = new URLSearchParams(location.search) 10 | for (const [key, value] of urlParams.entries()) { 11 | if (parseNumeric) { 12 | result[key] = /^\d+$/.test(value) ? Number(value) : value 13 | } else { 14 | result[key] = value 15 | } 16 | } 17 | return result 18 | }, [location.search, parseNumeric]) 19 | 20 | return params 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/CheckboxLabel/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import FormControlLabel from "@mui/material/FormControlLabel" 5 | import Checkbox from "@mui/material/Checkbox" 6 | 7 | import styles from "./index.module.scss" 8 | 9 | export default function CheckboxLabel(props) { 10 | const {label, checked, onChange, className, ...rest} = props 11 | 12 | return ( 13 | } 16 | label={label} 17 | onChange={(_, value) => onChange(value)} 18 | className={classnames(className, styles.root)} 19 | {...rest} 20 | /> 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/index.js: -------------------------------------------------------------------------------- 1 | import React, {memo} from "react" 2 | import classnames from "classnames" 3 | 4 | import CircularProgress from "@mui/material/CircularProgress" 5 | 6 | import styles from "./index.module.scss" 7 | 8 | const Loading = props => { 9 | const {color, value, className, classes = {}, children, ...rest} = props 10 | const progressStyle = color ? {color: color} : null 11 | 12 | return ( 13 |
17 | 21 |
22 | ) 23 | } 24 | 25 | export default memo(Loading) 26 | -------------------------------------------------------------------------------- /frontend/src/components/BottomPanel/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | import Container from "@mui/material/Container" 4 | 5 | import styles from "./index.module.scss" 6 | 7 | export default function BottomPanel(props) { 8 | const {isVisible, children, className, ...rest} = props 9 | 10 | return ( 11 |
19 | 20 |
21 | {children} 22 |
23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/SnackbarProvider/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {SnackbarProvider} from "notistack" 3 | import SnackbarMessage from "../SnackbarMessage" 4 | import Slide from "@mui/material/Slide" 5 | 6 | //import "./index.scss" 7 | 8 | const SnackbarProvider_ = ({children, ...rest}) => { 9 | return ( 10 | ( 18 | 19 | )} 20 | children={children} 21 | {...rest} 22 | /> 23 | ) 24 | } 25 | 26 | export default SnackbarProvider_ 27 | -------------------------------------------------------------------------------- /frontend/src/components/IntegrationTemplate/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Image from "../Image" 5 | import Card from "../Card" 6 | import Typography from "../Typography" 7 | 8 | import styles from "./index.module.scss" 9 | 10 | export default function IntegrationTemplate(props) { 11 | const {id, name, image, selected, className, ...rest} = props 12 | 13 | return ( 14 | 22 | 23 | {name} 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $font-family: "Manrope", "Roboto", "Helvetica", "Arial", sans-serif; 2 | $transition: all 0.5s cubic-bezier(0.25, 0.8, 0.05, 1); 3 | 4 | $header-height: 56px; 5 | $drawer-open-width: 300px; 6 | $drawer-minimized-width: 80px; 7 | 8 | $background-color: #1c1b1b; 9 | $paper-color: #32302f; 10 | $accent-color: #ef39a8; 11 | $hover-color: #bd399c; 12 | $error-color: #da0b20; 13 | $warning-color: #fcbc00; 14 | $positive-color: #0fb682; 15 | $block-placeholder-color: #423f3e; 16 | 17 | $emphasis-high: white; 18 | $emphasis-medium: fade-out(white, .24); 19 | $emphasis-outlined: fade-out(white, .38); 20 | 21 | $xl: 1920px - 1px; 22 | $lg: 1280px - 1px; 23 | $md: 960px - 1px; 24 | $sm: 600px - 1px; 25 | $xs: 480px - 1px; 26 | -------------------------------------------------------------------------------- /frontend/src/components/WhiteHole/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | padding: 8px 8px 8px 16px; 6 | border-radius: 12px; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | gap: 12px; 11 | cursor: pointer; 12 | &.selected { 13 | box-shadow: inset 0 0 0 3px $accent-color; 14 | } 15 | 16 | .left { 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | 21 | .imagesWrapper { 22 | position: relative; 23 | display: flex; 24 | flex-wrap: wrap; 25 | gap: 6px; 26 | width: 60px; 27 | height: 60px; 28 | flex: none; 29 | 30 | .image { 31 | width: calc(50% - 3px); 32 | height: calc(50% - 3px); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/AvailableSpace/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | padding: 12px 16px; 5 | 6 | .info { 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | margin-bottom: 12px; 11 | 12 | .left { 13 | line-height:1em; 14 | } 15 | 16 | .right { 17 | line-height:1em; 18 | } 19 | } 20 | 21 | .progress { 22 | position: relative; 23 | width: 100%; 24 | height: 6px; 25 | background-color: $block-placeholder-color; 26 | border-radius: 6px; 27 | overflow: hidden; 28 | 29 | .inner { 30 | position: absolute; 31 | top: 0; 32 | left: 0; 33 | height: 100%; 34 | background-color: $accent-color; 35 | width: 50%; 36 | border-top-right-radius: 6px; 37 | border-bottom-right-radius: 6px; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/Switch/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | $padding: 8px; 5 | $width: 44px; 6 | $height: 24px; 7 | $thumbSize: 18px; 8 | 9 | position: relative; 10 | width: $width + ($padding * 2); 11 | height: $height + ($padding * 2); 12 | padding: $padding; 13 | 14 | .track { 15 | background-color: $block-placeholder-color; 16 | opacity: 1; 17 | border-radius: $height; 18 | } 19 | .checked + .track { 20 | background-color: $accent-color; 21 | opacity: 1; 22 | } 23 | 24 | .switchBase { 25 | padding: ($height + ($padding * 2) - $thumbSize) / 2; 26 | 27 | .thumb { 28 | width: $thumbSize; 29 | height: $thumbSize; 30 | box-shadow: 0 2px 4px rgba(#00230b, 0.2); 31 | } 32 | } 33 | 34 | .checked.switchBase .thumb { 35 | background-color: white; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/components/Dialog/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | z-index: 1302 !important; 5 | backdrop-filter: blur(15px); 6 | 7 | .paper { 8 | margin: 14px; 9 | background-color: $background-color; 10 | transform: translate(0); //fix overflow 11 | 12 | .close { 13 | position: absolute; 14 | top: 6px; 15 | right: 6px; 16 | width: 24px; 17 | height: 24px; 18 | padding: 5px; 19 | cursor: pointer; 20 | flex: none; 21 | z-index: 9; 22 | color: $emphasis-high; 23 | 24 | svg { 25 | width: 26px; 26 | height: 26px; 27 | } 28 | } 29 | 30 | .title { 31 | //color: $light_main-color; 32 | } 33 | 34 | .text { 35 | //color: $light_regular-text-color; 36 | } 37 | 38 | .action { 39 | //overflow: hidden; 40 | //border-bottom-right-radius: 40px; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Discovery.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Deta Black Hole" 3 | tagline: "Your personal image hosting" 4 | theme_color: "#ef39a8" 5 | git: "https://github.com/mikhailsdv/deta-black-hole" 6 | homepage: "https://github.com/mikhailsdv/deta-black-hole" 7 | --- 8 | 9 | 🌀 Deta Black Hole is kind of black hole for your images. You just drop stuff and it eats it. 10 | 11 | Features: 12 | 13 | - Drag'n'drop multiple files; 14 | - Drag'n'drop images from another browser tabs; 15 | - Full screen drop zone; 16 | - Upload images via direct link; 17 | - Copy direct link to stored images; 18 | - Load more while scrolling; 19 | - Delete image button; 20 | - Group photos into folders (White Holes); 21 | - Share your White Holes; 22 | - Integration API; 23 | - Built-in Surfer image search; 24 | - Adaptive mobile version; 25 | - Your images sorted by upload date by default; 26 | - Available space indicator; 27 | - Other features I forgot about. 28 | 29 | So Deta Black Hole is the only black hole you'll be happy to see in Space 😎🚀 30 | -------------------------------------------------------------------------------- /frontend/src/components/IconButton/index.js: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from "react" 2 | import classnames from "classnames" 3 | 4 | import Button from "@mui/material/Button" 5 | import CircularProgress from "@mui/material/CircularProgress" 6 | 7 | import styles from "./index.module.scss" 8 | 9 | const IconButton_ = forwardRef((props, ref) => { 10 | const {children, small, variant, fullWidth, isLoading, className, ...rest} = 11 | props 12 | 13 | return ( 14 | 36 | ) 37 | }) 38 | 39 | export default IconButton_ 40 | -------------------------------------------------------------------------------- /frontend/src/components/Select/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | } 6 | 7 | .disabled { 8 | opacity: 0.7 !important; 9 | } 10 | 11 | .menuPaper { 12 | margin-top: 3px; 13 | box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08); 14 | } 15 | 16 | .InputRoot { 17 | //padding-top: 0 !important; 18 | //padding-bottom: 0 !important; 19 | //padding-left: 0 !important; 20 | //overflow: hidden; 21 | &:hover .notchedOutline { 22 | border-color: $emphasis-outlined; 23 | } 24 | .notchedOutline { 25 | border-color: $emphasis-outlined; 26 | } 27 | &.error { 28 | .notchedOutline { 29 | border-width: 2px; 30 | border-color: $error-color !important; 31 | } 32 | } 33 | &.focused { 34 | .notchedOutline { 35 | border-color: $accent-color; 36 | } 37 | } 38 | &.disabled { 39 | opacity: 0.7; 40 | } 41 | 42 | .input { 43 | padding: 23px 16px 10px 16px; 44 | } 45 | } 46 | 47 | .selectRoot { 48 | font-size: 16px; 49 | padding: 12px 20px; 50 | min-height: 16px; 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/components/Checkbox/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Checkbox from "@material-ui/core/Checkbox" 5 | 6 | import CheckboxUnchecked from "icons/CheckboxUnchecked" 7 | import CheckboxUncheckedDisabled from "icons/CheckboxUncheckedDisabled" 8 | import CheckboxChecked from "icons/CheckboxChecked" 9 | import CheckboxCheckedDisabled from "icons/CheckboxCheckedDisabled" 10 | 11 | import styles from "./index.module.scss" 12 | 13 | const Checkbox_ = props => { 14 | const {disabled, color, className, ...rest} = props 15 | 16 | return ( 17 | : 23 | } 24 | checkedIcon={ 25 | disabled ? ( 26 | 27 | ) : ( 28 | 29 | ) 30 | } 31 | disabled={disabled} 32 | {...rest} 33 | /> 34 | ) 35 | } 36 | 37 | export default Checkbox_ 38 | -------------------------------------------------------------------------------- /frontend/src/components/DoubleClick/index.js: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useRef} from "react" 2 | import {useSnackbar} from "notistack" 3 | 4 | export default function DoubleClick(props) { 5 | const { 6 | onClick, 7 | children, 8 | message, 9 | component: Component = "span", 10 | className, 11 | ...rest 12 | } = props 13 | 14 | const {enqueueSnackbar} = useSnackbar() 15 | const clickTimer = useRef(null) 16 | 17 | const confirmAction = useCallback( 18 | e => { 19 | if (e.detail === 1) { 20 | clickTimer.current = setTimeout(() => { 21 | enqueueSnackbar({ 22 | variant: "warning", 23 | message: message || "Double-click to confirm", 24 | }) 25 | }, 300) 26 | } 27 | }, 28 | [enqueueSnackbar, message] 29 | ) 30 | 31 | const onConfirm = useCallback(async () => { 32 | clearTimeout(clickTimer.current) 33 | onClick() 34 | }, [onClick]) 35 | 36 | return ( 37 | 43 | {children} 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/components/Link/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Link} from "react-router-dom" 3 | import classnames from "classnames" 4 | 5 | import styles from "./index.module.scss" 6 | 7 | const Link_ = props => { 8 | const { 9 | block, 10 | external, 11 | internal, 12 | to, 13 | blank, 14 | underline, 15 | children, 16 | className, 17 | ...rest 18 | } = props 19 | 20 | const aProps = { 21 | //fixes warning 22 | target: blank ? "_blank" : "_self", 23 | rel: blank ? "noreferrer noopener" : "", 24 | } 25 | 26 | return internal ? ( 27 | 37 | {children} 38 | 39 | ) : ( 40 | 51 | {children} 52 | 53 | ) 54 | } 55 | 56 | export default Link_ 57 | -------------------------------------------------------------------------------- /frontend/src/components/AvailableSpace/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | import prettyBytes from "pretty-bytes" 4 | 5 | import Card from "../Card" 6 | import Typography from "../Typography" 7 | 8 | import styles from "./index.module.scss" 9 | 10 | export default function AvailableSpace(props) { 11 | const {taken = 0, className, classes = {}, ...rest} = props 12 | let width = taken / 1_073_741_824 13 | width < 1 && width !== 0 && (width = 1) 14 | 15 | return ( 16 | 20 |
21 | 26 | Available space 27 | 28 | 34 | {prettyBytes(taken).replace(" ", "").toUpperCase()} / 10GB 35 | 36 |
37 |
38 |
39 |
40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/SnackbarMessage/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | display: flex; 6 | background-color: white; 7 | padding: 16px; 8 | align-items: flex-start; 9 | //max-width: $max-width; 10 | margin: 0 auto; 11 | width: 100%; 12 | overflow: hidden; 13 | border-radius: 8px; 14 | &.success { 15 | background-color: $positive-color; 16 | } 17 | &.error { 18 | background-color: $error-color; 19 | } 20 | &.warning { 21 | background-color: rgb(236, 134, 0); 22 | } 23 | &.santa { 24 | background-color: rgb(113,23,0); 25 | background-image: url("../../images/snowflakes.webp"); 26 | } 27 | &.default { 28 | background-color: $block-placeholder-color; 29 | } 30 | 31 | .message { 32 | flex-grow: 1; 33 | width: calc(100% - 24px - 12px); 34 | color: white; 35 | 36 | .messageIcon { 37 | color: inherit; 38 | display: inline; 39 | width: 1em; 40 | height: 1em; 41 | margin-right: 8px; 42 | vertical-align: middle; 43 | } 44 | 45 | a { 46 | color: inherit; 47 | text-decoration: underline; 48 | } 49 | } 50 | 51 | .closeIcon { 52 | flex: none; 53 | color: $emphasis-high; 54 | font-size: 24px; 55 | cursor: pointer; 56 | margin-left: 12px; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /frontend/src/api/useApi.js: -------------------------------------------------------------------------------- 1 | import {useRef} from "react" 2 | import request from "./request" 3 | import endpointsJSON from "./endpoints.json" 4 | 5 | const fillUrlParams = (url, params) => { 6 | for (const key in params) { 7 | url = url.replaceAll(`{${key}}`, params[key]) 8 | } 9 | return url 10 | //encodeURIComponent 11 | } 12 | 13 | export default function useApi() { 14 | const endpoints = useRef( 15 | //проверка на совпадения урлов, параметров, отсутствия слешей и т.д. 16 | endpointsJSON.reduce((acc, endpoint) => { 17 | const {name, params, method, url, headers, ...rest} = endpoint 18 | acc[name] = async (args = {}, options = {}) => { 19 | try { 20 | const {data} = await request({ 21 | url: fillUrlParams(url, args), 22 | method, 23 | [method === "get" ? "params" : "data"]: 24 | params && 25 | params.reduce((acc, param) => { 26 | acc[param] = args[param] 27 | return acc 28 | }, {}), 29 | headers, 30 | ...rest, 31 | ...options, 32 | }) 33 | 34 | return data 35 | } catch (err) { 36 | if (err?.response?.data) { 37 | return err.response.data 38 | } else { 39 | throw err 40 | } 41 | } 42 | } 43 | return acc 44 | }, {}) 45 | ) 46 | 47 | return endpoints.current 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/DroppedFile/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | border-radius: 8px; 6 | padding: 12px; 7 | width: 100%; 8 | display: flex; 9 | background-color: $paper-color; 10 | overflow: hidden; 11 | transition: $transition; 12 | &.fadeOut { 13 | opacity: 0; 14 | } 15 | &.error { 16 | background-color: fade-out($error-color, .7); 17 | 18 | .progress { 19 | opacity: 0; 20 | } 21 | } 22 | 23 | .progress { 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | height: 100%; 28 | transition: $transition; 29 | z-index: 1; 30 | background-color: fade-out($accent-color, .7); 31 | 32 | &.finished { 33 | background-color: fade-out($positive-color, .7); 34 | } 35 | } 36 | 37 | .image { 38 | flex: none; 39 | width: 80px; 40 | height: 80px; 41 | border-radius: 6px; 42 | margin-right: 16px; 43 | object-fit: cover; 44 | object-position: center; 45 | background-color: $block-placeholder-color; 46 | z-index: 2; 47 | } 48 | 49 | .info { 50 | z-index: 2; 51 | display: flex; 52 | flex-direction: column; 53 | justify-content: center; 54 | min-height: 80px; 55 | 56 | .tryAgain { 57 | color: $error-color; 58 | text-decoration: underline; 59 | cursor: pointer; 60 | font-weight: 500; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/ExternalFile/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | border-radius: 8px; 6 | padding: 12px; 7 | width: 100%; 8 | display: flex; 9 | background-color: $paper-color; 10 | overflow: hidden; 11 | transition: $transition; 12 | &.fadeOut { 13 | opacity: 0; 14 | } 15 | &.error { 16 | background-color: fade-out($error-color, .7); 17 | 18 | .progress { 19 | opacity: 0; 20 | } 21 | } 22 | 23 | .progress { 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | height: 100%; 28 | transition: $transition; 29 | z-index: 1; 30 | background-color: fade-out($accent-color, .7); 31 | 32 | &.finished { 33 | background-color: fade-out($positive-color, .7); 34 | } 35 | } 36 | 37 | .image { 38 | flex: none; 39 | width: 80px; 40 | height: 80px; 41 | border-radius: 6px; 42 | margin-right: 16px; 43 | object-fit: cover; 44 | object-position: center; 45 | background-color: $block-placeholder-color; 46 | z-index: 2; 47 | } 48 | 49 | .info { 50 | z-index: 2; 51 | display: flex; 52 | flex-direction: column; 53 | justify-content: center; 54 | min-height: 80px; 55 | 56 | .tryAgain { 57 | color: $error-color; 58 | text-decoration: underline; 59 | cursor: pointer; 60 | font-weight: 500; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/IconButton/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | height: 54px; 6 | width: 54px; 7 | min-width: 54px; 8 | border-radius: 8px; 9 | padding: 0; 10 | outline: none; 11 | transition: $transition; 12 | &[disabled] { 13 | opacity: 0.6; 14 | filter: saturate(0.9) contrast(0.6); 15 | pointer-events: none; 16 | } 17 | &.small { 18 | height: 44px; 19 | width: 44px; 20 | min-width: 44px; 21 | } 22 | &.fullWidth { 23 | width: 100%; 24 | } 25 | 26 | &.primary { 27 | color: white; 28 | background: $accent-color; 29 | .preloader { 30 | color: white; 31 | } 32 | 33 | &:not([disabled]):hover { 34 | background-color: $hover-color; 35 | } 36 | } 37 | 38 | &.secondary { 39 | background-color: fade-out($accent-color, 0.9); 40 | color: $hover-color; 41 | transition: $transition; 42 | .preloader { 43 | color: $hover-color; 44 | } 45 | 46 | &:not([disabled]):hover { 47 | background-color: fade-out($accent-color, 0.8); 48 | } 49 | } 50 | 51 | &.negative { 52 | background-color: $error-color; 53 | color: #ffffff; 54 | } 55 | 56 | &.loading { 57 | pointer-events: none; 58 | 59 | .preloader { 60 | opacity: 1; 61 | } 62 | } 63 | 64 | .preloader { 65 | color: inherit; 66 | } 67 | .icon { 68 | color: inherit; 69 | 70 | & > svg { 71 | width: 24px; 72 | height: 24px; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/components/RadioGroup/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import FormControl from "@mui/material/FormControl" 5 | import FormLabel from "@mui/material/FormLabel" 6 | import RadioGroup from "@mui/material/RadioGroup" 7 | import FormControlLabel from "@mui/material/FormControlLabel" 8 | import Radio from "@mui/material/Radio" 9 | import Typography from "../Typography" 10 | 11 | import styles from "./index.module.scss" 12 | 13 | export default function _RadioGroup(props) { 14 | const { 15 | title, 16 | options, 17 | value, 18 | onChange, 19 | id, 20 | row, 21 | className, 22 | classes = {}, 23 | ...rest 24 | } = props 25 | 26 | return ( 27 | 31 | {title && ( 32 | 33 | {title} 34 | 35 | )} 36 | onChange(value)} 41 | className={classnames(row && styles.row)} 42 | > 43 | {options.map(option => ( 44 | } 48 | label={option.label} 49 | className={classnames(row && styles.mr)} 50 | /> 51 | ))} 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/components/WhiteHole/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | import urlJoin from "url-join" 4 | 5 | import Image from "../Image" 6 | import Card from "../Card" 7 | import Typography from "../Typography" 8 | import Link from "../Link" 9 | 10 | import styles from "./index.module.scss" 11 | 12 | export default function WhiteHole(props) { 13 | const { 14 | id, 15 | name, 16 | link = true, 17 | small, 18 | selected, 19 | images, 20 | loading, 21 | is_public, 22 | className, 23 | ...rest 24 | } = props 25 | 26 | const content = ( 27 | 35 |
36 | {name || "No name"} 37 | 38 | {loading 39 | ? "Please, wait..." 40 | : is_public 41 | ? "Public" 42 | : "Private"} 43 | 44 |
45 |
46 | {images.map(image => ( 47 | 55 | ))} 56 |
57 |
58 | ) 59 | 60 | return link ? ( 61 | 62 | {content} 63 | 64 | ) : ( 65 | content 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Button from "@mui/material/Button" 5 | import Typography from "@mui/material/Typography" 6 | import CircularProgress from "@mui/material/CircularProgress" 7 | 8 | import styles from "./index.module.scss" 9 | 10 | const Button_ = props => { 11 | const { 12 | fullWidth, 13 | small, 14 | tiny, 15 | loadingText, 16 | children, 17 | iconAfter: IconAfter, 18 | iconBefore: IconBefore, 19 | variant, 20 | isLoading, 21 | className, 22 | ...rest 23 | } = props 24 | 25 | return ( 26 | 60 | ) 61 | } 62 | 63 | export default Button_ 64 | -------------------------------------------------------------------------------- /frontend/src/components/SnackbarMessage/index.js: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from "react" 2 | import classnames from "classnames" 3 | import {useSnackbar, SnackbarContent} from "notistack" 4 | 5 | import {FlatIcon, createFlatIcon} from "../FlatIcon" 6 | import Typography from "../Typography" 7 | 8 | import styles from "./index.module.scss" 9 | 10 | const SnackbarMessage = forwardRef((props, ref) => { 11 | const {closeSnackbar} = useSnackbar() 12 | const { 13 | id, 14 | title, 15 | content, 16 | message, 17 | variant, 18 | className, 19 | classes = {}, 20 | ...rest 21 | } = props 22 | 23 | const Icon = 24 | variant && 25 | { 26 | success: createFlatIcon("fi-br-check"), 27 | default: createFlatIcon("fi-br-comment"), 28 | error: createFlatIcon("fi-br-exclamation"), 29 | warning: createFlatIcon("fi-br-exclamation"), 30 | santa: createFlatIcon("fi-br-tree-christmas"), 31 | }[variant] 32 | 33 | return ( 34 | 44 | {message && ( 45 | 50 | {variant && } 51 | {message} 52 | 53 | )} 54 |
closeSnackbar(id)}> 55 | 56 |
57 |
58 | ) 59 | }) 60 | 61 | export default SnackbarMessage 62 | -------------------------------------------------------------------------------- /frontend/src/components/Dialog/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Dialog from "@mui/material/Dialog" 5 | import DialogTitle from "@mui/material/DialogTitle" 6 | import DialogContent from "@mui/material/DialogContent" 7 | import DialogActions from "@mui/material/DialogActions" 8 | import DialogContentText from "@mui/material/DialogContentText" 9 | 10 | //import {FlatIcon, createFlatIcon} from "../FlatIcon" 11 | 12 | import styles from "./index.module.scss" 13 | 14 | const Dialog_ = props => { 15 | const { 16 | title, 17 | text, 18 | /*onClose,*/ actions, 19 | action, 20 | classes, 21 | children, 22 | className, 23 | ...rest 24 | } = props 25 | 26 | return ( 27 | 38 | {/*
39 | 40 |
*/} 41 | 42 | {title && ( 43 | {title} 44 | )} 45 | {text && ( 46 | 47 | {text} 48 | 49 | )} 50 | {children && ( 51 | 52 | {children} 53 | 54 | )} 55 | {actions && {actions}} 56 |
{action}
57 |
58 | ) 59 | } 60 | 61 | export default Dialog_ 62 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | Deta Black Hole 28 | 29 | 43 | 44 | 45 | 46 |
Bugs? Press Ctrl + Shift + R to clear cache
47 | 48 | 49 | -------------------------------------------------------------------------------- /frontend/src/components/Santa/index.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useCallback} from "react" 2 | import classnames from "classnames" 3 | import {useSnackbar} from "notistack" 4 | import {arrayRandom} from "../../functions/utils" 5 | 6 | import santaImage from "./santa.png" 7 | 8 | import styles from "./index.module.scss" 9 | 10 | export default function Santa() { 11 | const {enqueueSnackbar} = useSnackbar() 12 | 13 | const [side, setSide] = useState("") 14 | const [top, setTop] = useState(100) 15 | 16 | const onClick = useCallback(() => { 17 | const phrases = [ 18 | "Happy New Year! 💥!", 19 | "You didn't see me! 🤫", 20 | "You behaved well this year, right? 🤔?", 21 | "Why don't you write me letters anymore?  🙁", 22 | "They told me you don't believe in me, is that true? 😢", 23 | "Catch me if you can 🙈", 24 | "I climbed up the chimney and fell into a black hole 😜", 25 | "Can you take a screenshot of me? 😊", 26 | ] 27 | enqueueSnackbar({ 28 | variant: "santa", 29 | message: arrayRandom(phrases), 30 | }) 31 | }, [enqueueSnackbar]) 32 | 33 | useEffect(() => { 34 | let t 35 | ;(function loop() { 36 | setSide([styles.right, styles.left][Math.round(Math.random())]) 37 | setTop( 38 | Math.round( 39 | 54 + Math.random() * (window.innerHeight - 54 - 54 - 144) 40 | ) 41 | ) 42 | setTimeout(() => { 43 | setSide("") 44 | }, 2000) 45 | t = setTimeout(loop, 60000) 46 | })() 47 | 48 | return () => clearTimeout(t) 49 | }, []) 50 | 51 | return ( 52 | santa 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/components/TextField/index.js: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useState} from "react" 2 | import classnames from "classnames" 3 | 4 | import TextField from "@mui/material/TextField" 5 | import Typography from "@mui/material/Typography" 6 | 7 | import styles from "./index.module.scss" 8 | 9 | const TextField_ = props => { 10 | const { 11 | error, 12 | helperText, 13 | icon: Icon, 14 | maskProps, 15 | className, 16 | InputProps, 17 | classes = {}, 18 | children, 19 | ...rest 20 | } = props 21 | 22 | const [isFocused, setIsFocused] = useState(false) 23 | 24 | const onFocus = useCallback(() => { 25 | setIsFocused(true) 26 | }, []) 27 | const onBlur = useCallback(() => { 28 | setIsFocused(false) 29 | }, []) 30 | 31 | return ( 32 |
33 |
43 | {Icon && } 44 | 62 |
63 | {helperText && ( 64 | 71 | {helperText} 72 | 73 | )} 74 |
75 | ) 76 | } 77 | 78 | export default TextField_ 79 | -------------------------------------------------------------------------------- /frontend/src/components/Photo/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | padding: 8px; 6 | border-radius: 12px; 7 | 8 | .imageWrapper { 9 | position: relative; 10 | width: 100%; 11 | padding-top: 74%; 12 | 13 | .image { 14 | position: absolute; 15 | top: 0; 16 | width: 100%; 17 | height: 100%; 18 | cursor: pointer; 19 | transition: $transition; 20 | &:hover { 21 | filter: brightness(50%); 22 | } 23 | } 24 | 25 | .info { 26 | position: absolute; 27 | bottom: 4px; 28 | left: 4px; 29 | padding: 5px 6px; 30 | border-radius: 6px; 31 | font-size: 10px; 32 | line-height:1em; 33 | background-color: $background-color; 34 | cursor: default; 35 | color: $emphasis-high; 36 | opacity: .6; 37 | z-index: 1; 38 | transition: $transition; 39 | &:hover { 40 | opacity:1; 41 | } 42 | } 43 | 44 | .checkbox { 45 | position: absolute; 46 | top: 4px; 47 | left: 4px; 48 | width: 24px; 49 | height: 24px; 50 | border-radius: 8px; 51 | font-size: 14px; 52 | background-color: fade-out($accent-color, 0.9); 53 | border: 2px solid fade-out($accent-color, 0.4); 54 | cursor: pointer; 55 | color: transparent; 56 | z-index: 1; 57 | transition: $transition; 58 | overflow: hidden; 59 | &.checked { 60 | background-color: $accent-color; 61 | color: white; 62 | } 63 | 64 | i { 65 | display: block; 66 | text-align: center; 67 | width: 16px; 68 | height: 16px; 69 | font-size: 16px; 70 | margin: 2px; 71 | } 72 | } 73 | } 74 | 75 | .download { 76 | margin-top: 8px; 77 | } 78 | 79 | .actions { 80 | width: 100%; 81 | display: flex; 82 | justify-content: space-between; 83 | gap: 8px; 84 | margin-top: 6px; 85 | 86 | & > * { 87 | flex-grow: 1; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/components/FileUploadField/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | width: 100%; 6 | display: inline-block; 7 | overflow: hidden; 8 | border: 8px dotted $block-placeholder-color; 9 | border-radius: 10px; 10 | padding: 12px; 11 | 12 | .input { 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | height: 100%; 17 | width: 100%; 18 | opacity: 0; 19 | z-index: 9; 20 | cursor: pointer; 21 | } 22 | 23 | .fullScreenDrop { 24 | position: fixed; 25 | top: 0; 26 | left: 0; 27 | width: 100%; 28 | height: 100vh; 29 | z-index: 9999; 30 | padding: 16px; 31 | background-color: fade-out(white, .2); 32 | display: none; 33 | &.visible { 34 | display: block; 35 | } 36 | 37 | .fullScreenDropArea { 38 | position: relative; 39 | width: 100%; 40 | height: 100%; 41 | overflow: hidden; 42 | border: 12px dotted $block-placeholder-color; 43 | border-radius: 10px; 44 | padding: 12px; 45 | font-size: 22px; 46 | font-weight: bold; 47 | display: flex; 48 | align-items: center; 49 | justify-content: center; 50 | text-align: center; 51 | color: $block-placeholder-color; 52 | } 53 | } 54 | 55 | .dropArea { 56 | position: relative; 57 | min-height: 300px; 58 | width: 100%; 59 | z-index: 8; 60 | padding: 8px; 61 | font-size: 18px; 62 | font-weight: bold; 63 | display: flex; 64 | align-items: center; 65 | justify-content: center; 66 | text-align: center; 67 | color: $block-placeholder-color; 68 | @media screen and (max-width: $sm) { 69 | min-height: 200px; 70 | } 71 | } 72 | .droppedFilesContainer { 73 | position: relative; 74 | width: 100%; 75 | max-height: 800px; 76 | z-index: 7; 77 | overflow: auto; 78 | 79 | .droppedFile { 80 | margin-bottom: 12px; 81 | } 82 | 83 | & > *:last-child .droppedFile { 84 | margin-bottom: 0; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.1.13", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.10.4", 7 | "@emotion/styled": "^11.10.4", 8 | "@mui/material": "^5.10.12", 9 | "@testing-library/jest-dom": "^5.16.5", 10 | "@testing-library/react": "^13.4.0", 11 | "@testing-library/user-event": "^13.5.0", 12 | "axios": "^1.1.3", 13 | "axios-retry": "^3.3.1", 14 | "classnames": "^2.3.2", 15 | "copy-image-clipboard": "^2.1.2", 16 | "copy-to-clipboard": "^3.3.2", 17 | "md5": "^2.3.0", 18 | "mime": "^3.0.0", 19 | "notistack": "^2.0.8", 20 | "pretty-bytes": "^6.0.0", 21 | "react": "^18.2.0", 22 | "react-code-blocks": "^0.0.9-0", 23 | "react-dom": "^18.2.0", 24 | "react-github-btn": "^1.4.0", 25 | "react-router-dom": "^6.4.3", 26 | "react-router-scroll-to-top": "^1.2.0", 27 | "react-scripts": "5.0.1", 28 | "ua-parser-js": "^1.0.32", 29 | "url-join": "^5.0.0", 30 | "web-vitals": "^2.1.4" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "react-scripts build", 35 | "test": "react-scripts test", 36 | "eject": "react-scripts eject" 37 | }, 38 | "eslintConfig": { 39 | "extends": [ 40 | "react-app", 41 | "react-app/jest" 42 | ] 43 | }, 44 | "browserslist": { 45 | "production": [ 46 | ">0.2%", 47 | "not dead", 48 | "not op_mini all" 49 | ], 50 | "development": [ 51 | "last 1 chrome version", 52 | "last 1 firefox version", 53 | "last 1 safari version" 54 | ] 55 | }, 56 | "devDependencies": { 57 | "autoprefixer": "^10.4.12", 58 | "eslint": "8.22.0", 59 | "postcss": "^8.4.18", 60 | "prettier": "^2.7.1", 61 | "sass": "^1.55.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/Image/index.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useCallback, useState} from "react" 2 | import classnames from "classnames" 3 | import notFoundImage from "../../images/not-found.png" 4 | import styles from "./index.module.scss" 5 | 6 | export default function Image(props) { 7 | const { 8 | src, 9 | thumbnail, 10 | onLoad: onLoadProp, 11 | transparent, 12 | className, 13 | classes = {}, 14 | ...rest 15 | } = props 16 | 17 | const [isLoaded, setLoaded] = useState(false) 18 | const [imageSrc, setImageSrc] = useState(thumbnail || src) 19 | const [isError, setIsError] = useState(false) 20 | const imgEl = useRef(null) 21 | 22 | const onLoad = useCallback( 23 | e => { 24 | setLoaded(true) 25 | onLoadProp && onLoadProp(e.target) 26 | thumbnail && setImageSrc(src) 27 | }, 28 | [onLoadProp, src, thumbnail] 29 | ) 30 | 31 | const onError = useCallback(e => { 32 | setIsError(true) 33 | }, []) 34 | 35 | useEffect(() => { 36 | if (thumbnail && src) { 37 | const img = document.createElement("img") 38 | img.src = src 39 | img.addEventListener("load", onLoad) 40 | img.addEventListener("error", onError) 41 | return () => { 42 | img.removeEventListener("load", onLoad) 43 | img.removeEventListener("error", onError) 44 | } 45 | } else { 46 | const img = imgEl.current 47 | img.addEventListener("load", onLoad) 48 | img.addEventListener("error", onError) 49 | return () => { 50 | img.removeEventListener("load", onLoad) 51 | img.removeEventListener("error", onError) 52 | } 53 | } 54 | }, [thumbnail, src, onLoad, onError]) 55 | 56 | const actualSrc = isError ? notFoundImage : imageSrc 57 | 58 | return ( 59 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/functions/utils.js: -------------------------------------------------------------------------------- 1 | const mime = require("mime") 2 | 3 | const pluralize = (n, singular, plural, accusative) => { 4 | n = Math.abs(n) 5 | const n10 = n % 10 6 | const n100 = n % 100 7 | if (n10 === 1 && n100 !== 11) { 8 | return singular 9 | } 10 | if (2 <= n10 && n10 <= 4 && !(12 <= n100 && n100 <= 14)) { 11 | return plural 12 | } 13 | return accusative 14 | } 15 | 16 | const downloadFile = (url, filename) => { 17 | const a = document.createElement("a") 18 | a.href = url 19 | a.setAttribute("target", "_blank") 20 | a.setAttribute("download", filename) 21 | a.click() 22 | a.remove() 23 | } 24 | 25 | const numberWithSpaces = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, " ") 26 | 27 | const sleep = time => new Promise(r => setTimeout(r, time)) 28 | 29 | const getFileFromImageUrl = url => 30 | new Promise((resolve, reject) => { 31 | const img = document.createElement("img") 32 | img.src = url 33 | img.setAttribute("crossorigin", "anonymous") 34 | img.addEventListener("load", async () => { 35 | try { 36 | const response = await fetch(url) 37 | const data = await response.blob() 38 | const extension = mime.getExtension(data.type) || "jpg" 39 | const canvas = document.createElement("canvas") 40 | canvas.width = img.width 41 | canvas.height = img.height 42 | const ctx = canvas.getContext("2d") 43 | ctx.drawImage(img, 0, 0) 44 | canvas.toBlob( 45 | blob => { 46 | resolve( 47 | new File([blob], `image.${extension}`, { 48 | type: data.type, 49 | }) 50 | ) 51 | }, 52 | data.type, 53 | 1 54 | ) 55 | } catch (err) { 56 | console.error(err) 57 | reject("Can't load the image") 58 | } 59 | }) 60 | img.addEventListener("error", e => { 61 | console.error(e) 62 | reject("Not an image") 63 | }) 64 | }) 65 | 66 | const arrayRandom = arr => { 67 | return arr[Math.floor(Math.random() * arr.length)] 68 | } 69 | 70 | export { 71 | pluralize, 72 | sleep, 73 | numberWithSpaces, 74 | downloadFile, 75 | getFileFromImageUrl, 76 | arrayRandom, 77 | } 78 | -------------------------------------------------------------------------------- /frontend/src/components/SurferSearch/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: relative; 5 | overflow: hidden; 6 | 7 | .searchWrapper { 8 | display: flex; 9 | gap: 8px; 10 | width: 100%; 11 | 12 | & > *:first-child { 13 | flex-grow: 1; 14 | } 15 | 16 | .clear { 17 | color: $emphasis-outlined; 18 | } 19 | 20 | .searchButton { 21 | position: relative; 22 | flex: none; 23 | width: 54px; 24 | height: 54px; 25 | background-color: #1106d6; 26 | cursor: pointer; 27 | outline: none; 28 | border-radius: 12px; 29 | overflow: hidden; 30 | border: 3px solid #423f3e; 31 | transition: $transition; 32 | &:hover { 33 | filter: brightness(0.9); 34 | } 35 | 36 | img { 37 | position: absolute; 38 | top: 0; 39 | left: 0; 40 | width: 100%; 41 | height: 100%; 42 | object-position: center; 43 | object-fit: cover; 44 | } 45 | 46 | .progress { 47 | color: $accent-color; 48 | position: absolute; 49 | top: 0; 50 | left: 0; 51 | right: 0; 52 | bottom: 0; 53 | margin: auto; 54 | } 55 | } 56 | } 57 | 58 | .credits { 59 | margin-bottom: 8px; 60 | } 61 | 62 | .images { 63 | position: relative; 64 | width: 100%; 65 | margin-top: 12px; 66 | padding: 8px; 67 | 68 | .imageWrapper { 69 | position: relative; 70 | border-radius: 8px; 71 | overflow: hidden; 72 | cursor: pointer; 73 | &:hover { 74 | .clickToSave { 75 | opacity: 1; 76 | } 77 | } 78 | 79 | .clickToSave { 80 | position: absolute; 81 | width: 100%; 82 | height: 100%; 83 | display: flex; 84 | align-items: center; 85 | justify-content: center; 86 | text-align: center; 87 | z-index: 1; 88 | background-color: rgba(0, 0, 0, .6); 89 | opacity: 0; 90 | transition: $transition; 91 | } 92 | 93 | .image { 94 | width: 100%; 95 | display: block; 96 | aspect-ratio: 1/1; 97 | object-fit: contain; 98 | background-color: $block-placeholder-color; 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /frontend/src/components/Santa/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .root { 4 | position: fixed; 5 | z-index: 10; 6 | display: none; 7 | top: 20px; 8 | width: 110px; 9 | //transform-origin: bottom; 10 | -webkit-animation-duration: 1.5s; 11 | animation-duration: 1.5s; 12 | -webkit-animation-fill-mode: both; 13 | animation-fill-mode: both; 14 | animation-timing-function: cubic-bezier(.25,.8,.05,1); 15 | 16 | &.left { 17 | display: block; 18 | left: -110px; 19 | -webkit-animation-name: santa-left; 20 | animation-name: santa-left; 21 | transform-origin: 90% 90%; 22 | } 23 | 24 | &.right { 25 | display: block; 26 | right: -110px; 27 | -webkit-animation-name: santa-right; 28 | animation-name: santa-right; 29 | transform-origin: 10% 90%; 30 | } 31 | } 32 | 33 | @-webkit-keyframes santa-right { 34 | from, 35 | to { 36 | transform: translate3d(0, 0, 0); 37 | } 38 | 39 | 10%, 40 | 30%, 41 | 50%, 42 | 70%, 43 | 90% { 44 | transform: rotate(-75deg); 45 | } 46 | 47 | 20%, 48 | 40%, 49 | 60%, 50 | 80% { 51 | transform: rotate(-65deg); 52 | } 53 | 54 | 0%, 55 | 100% { 56 | transform: rotate(0deg); 57 | } 58 | } 59 | 60 | @keyframes santa-right { 61 | from, 62 | to { 63 | transform: translate3d(0, 0, 0); 64 | } 65 | 66 | 10%, 67 | 30%, 68 | 50%, 69 | 70%, 70 | 90% { 71 | transform: rotate(-75deg); 72 | } 73 | 74 | 20%, 75 | 40%, 76 | 60%, 77 | 80% { 78 | transform: rotate(-65deg); 79 | } 80 | 81 | 0%, 82 | 100% { 83 | transform: rotate(0deg); 84 | } 85 | } 86 | 87 | @-webkit-keyframes santa-left { 88 | 10%, 89 | 30%, 90 | 50%, 91 | 70%, 92 | 90% { 93 | transform: rotate(75deg); 94 | } 95 | 96 | 20%, 97 | 40%, 98 | 60%, 99 | 80% { 100 | transform: rotate(65deg); 101 | } 102 | 103 | 0%, 104 | 100% { 105 | transform: rotate(0deg); 106 | } 107 | } 108 | 109 | @keyframes santa-left { 110 | from, 111 | to { 112 | transform: translate3d(0, 0, 0); 113 | } 114 | 115 | 10%, 116 | 30%, 117 | 50%, 118 | 70%, 119 | 90% { 120 | transform: rotate(75deg); 121 | } 122 | 123 | 20%, 124 | 40%, 125 | 60%, 126 | 80% { 127 | transform: rotate(65deg); 128 | } 129 | 130 | 0%, 131 | 100% { 132 | transform: rotate(0deg); 133 | } 134 | } -------------------------------------------------------------------------------- /frontend/src/components/TextField/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables.scss"; 2 | 3 | .wrapper { 4 | position: relative; 5 | } 6 | 7 | .root { 8 | position: relative; 9 | width: 100%; 10 | vertical-align: top; 11 | border-radius: 10px; 12 | box-shadow: inset 0 0 0 3px $block-placeholder-color; 13 | overflow: hidden; 14 | display: flex; 15 | align-items: center; 16 | transition: $transition; 17 | &.focused { 18 | box-shadow: inset 0 0 0 3px $accent-color; 19 | } 20 | &.error { 21 | box-shadow: inset 0 0 0 3px $error-color; 22 | } 23 | 24 | .icon { 25 | position: relative; 26 | width: 24px; 27 | height: 24px; 28 | margin-left: 16px; 29 | flex: none; 30 | color: $emphasis-medium; 31 | transition: $transition; 32 | z-index: 9; 33 | } 34 | &.focused .icon { 35 | color: $accent-color; 36 | } 37 | &.error .icon { 38 | color: $error-color; 39 | } 40 | } 41 | 42 | .Input { 43 | background-color: transparent !important; 44 | color: $emphasis-high; 45 | &:after { 46 | content: unset; 47 | } 48 | &:before { 49 | content: unset; 50 | } 51 | 52 | .input { 53 | background-color: transparent !important; 54 | padding: 22px 16px 9px 16px; 55 | 56 | &:-internal-autofill-previewed, 57 | &:-internal-autofill-selected, 58 | &:-webkit-autofill::first-line, 59 | &:-webkit-autofill, 60 | &:-webkit-autofill:hover, 61 | &:-webkit-autofill:focus, 62 | &:-webkit-autofill:active { 63 | -webkit-text-fill-color: $emphasis-high !important; 64 | font-family: $font-family !important; 65 | -webkit-background-clip: text; 66 | } 67 | 68 | &[type="number"] { 69 | -moz-appearance: textfield; 70 | &::-webkit-outer-spin-button, 71 | &::-webkit-inner-spin-button { 72 | -webkit-appearance: none; 73 | margin: 0; 74 | } 75 | &::outer-spin-button, 76 | &::inner-spin-button { 77 | -webkit-appearance: none; 78 | margin: 0; 79 | } 80 | } 81 | } 82 | } 83 | 84 | .label { 85 | white-space: nowrap; 86 | color: $emphasis-medium !important; 87 | transform: translate(16px, 17px) scale(1); 88 | } 89 | .labelShrink { 90 | transform: translate(16px, 8px) scale(0.65); 91 | } 92 | .labelDisabled { 93 | opacity: 0.7; 94 | } 95 | 96 | .helperText { 97 | margin: 2px 0 0 0; 98 | color: $emphasis-outlined; 99 | line-height: 1.4em; 100 | &.error { 101 | color: $error-color; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/api/endpoints.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "uploadPhoto", 4 | "url": "/photo", 5 | "method": "post", 6 | "params": ["photo"], 7 | "headers": { 8 | "Content-Type": "multipart/form-data" 9 | }, 10 | "timeout": 0 11 | }, 12 | { 13 | "name": "getPhotos", 14 | "url": "/photos", 15 | "method": "get", 16 | "params": ["limit", "offset"] 17 | }, 18 | { 19 | "name": "getPhoto", 20 | "url": "/photo/{drive_name}", 21 | "method": "get" 22 | }, 23 | { 24 | "name": "deletePhotos", 25 | "url": "/photos", 26 | "method": "delete", 27 | "params": ["ids"] 28 | }, 29 | { 30 | "name": "getSinglePhoto", 31 | "url": "/key/{key}", 32 | "method": "get" 33 | }, 34 | { 35 | "name": "download", 36 | "url": "/download", 37 | "method": "post", 38 | "params": ["url"], 39 | "timeout": 30000 40 | }, 41 | { 42 | "name": "getPrivateWhiteHole", 43 | "url": "/white-hole/private/{key}", 44 | "method": "get" 45 | }, 46 | { 47 | "name": "getPublicWhiteHole", 48 | "url": "/white-hole/public/{key}", 49 | "method": "get" 50 | }, 51 | { 52 | "name": "getWhiteHoles", 53 | "url": "/white-holes", 54 | "method": "get", 55 | "params": ["limit", "offset"] 56 | }, 57 | { 58 | "name": "createWhiteHole", 59 | "url": "/white-hole", 60 | "method": "post", 61 | "params": ["images", "is_public", "name"] 62 | }, 63 | { 64 | "name": "editWhiteHole", 65 | "url": "/white-hole/{key}", 66 | "method": "put", 67 | "params": ["is_public"] 68 | }, 69 | { 70 | "name": "deleteWhiteHole", 71 | "url": "/white-hole/{key}", 72 | "method": "delete" 73 | }, 74 | { 75 | "name": "deletePhotosFromWhiteHole", 76 | "url": "/white-hole/photos", 77 | "method": "delete", 78 | "params": ["white_hole_key", "ids"] 79 | }, 80 | { 81 | "name": "addPhotosToWhiteHole", 82 | "url": "/white-hole/photos", 83 | "method": "put", 84 | "params": ["white_hole_key", "ids"] 85 | }, 86 | { 87 | "name": "createIntegration", 88 | "url": "/integration", 89 | "method": "post", 90 | "params": ["name"] 91 | }, 92 | { 93 | "name": "deleteIntegration", 94 | "url": "/integration", 95 | "method": "delete", 96 | "params": ["key"] 97 | }, 98 | { 99 | "name": "getIntegrations", 100 | "url": "/integration", 101 | "method": "get" 102 | }, 103 | { 104 | "name": "searchSurfer", 105 | "url": "/search-surfer", 106 | "method": "get", 107 | "params": ["query", "results"] 108 | }, 109 | { 110 | "name": "getSettings", 111 | "url": "/settings", 112 | "method": "get" 113 | }, 114 | { 115 | "name": "setSettings", 116 | "url": "/settings", 117 | "method": "put", 118 | "params": ["key", "value"] 119 | } 120 | ] 121 | -------------------------------------------------------------------------------- /frontend/src/components/Select/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import classnames from "classnames" 3 | 4 | import Select from "@mui/material/Select" 5 | import FormControl from "@mui/material/FormControl" 6 | import InputLabel from "@mui/material/InputLabel" 7 | import OutlinedInput from "@mui/material/OutlinedInput" 8 | import FormHelperText from "@mui/material/FormHelperText" 9 | 10 | import styles from "./index.module.scss" 11 | import textFieldStyles from "../TextField/index.module.scss" 12 | import Typography from "@mui/material/Typography" 13 | 14 | const Select_ = props => { 15 | const { 16 | MenuProps = {}, 17 | label, 18 | error, 19 | helperText, 20 | disabled, 21 | className, 22 | classes = {}, 23 | ...rest 24 | } = props 25 | 26 | const id = `id${Math.random().toString(32)}` 27 | 28 | return ( 29 | 34 | 46 | 180 | {placeholder} 181 |
182 | 183 | ) 184 | } 185 | 186 | export default FileUploadField 187 | -------------------------------------------------------------------------------- /frontend/src/App/index.module.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/variables.scss"; 2 | 3 | .mb6 { 4 | margin-bottom: 6px; 5 | } 6 | 7 | .mb12 { 8 | margin-bottom: 12px; 9 | } 10 | 11 | .container { 12 | min-height: 100vh; 13 | padding-top: 42px; 14 | padding-bottom: 84px; 15 | 16 | .opacity08 { 17 | opacity: .8; 18 | } 19 | 20 | .logo { 21 | position: relative; 22 | display: inline; 23 | margin-right: 12px; 24 | top: 5px; 25 | width: 1em; 26 | height: 1em; 27 | } 28 | 29 | .header { 30 | display: flex; 31 | align-items: center; 32 | justify-content: space-between; 33 | @media screen and (max-width: $sm) { 34 | flex-direction: column; 35 | justify-content: flex-start; 36 | align-items: flex-start; 37 | } 38 | 39 | & > *:last-child { 40 | height: 20px; 41 | @media screen and (max-width: $sm) { 42 | margin-top: 6px; 43 | margin-bottom: 12px; 44 | } 45 | } 46 | } 47 | 48 | .linkBlock { 49 | display: flex; 50 | gap: 12px; 51 | margin-top: 12px; 52 | 53 | .orText { 54 | opacity: .7; 55 | text-decoration: underline; 56 | cursor: pointer; 57 | flex-grow: unset!important; 58 | } 59 | 60 | & > *:first-child { 61 | flex-grow: 1; 62 | } 63 | } 64 | 65 | .libraryHeader { 66 | display: flex; 67 | justify-content: space-between; 68 | gap: 16px; 69 | align-items: center; 70 | @media screen and (max-width: $sm) { 71 | flex-direction: column; 72 | justify-content: flex-start; 73 | align-items: flex-start; 74 | } 75 | 76 | .left { 77 | display: flex; 78 | flex-direction: column; 79 | justify-content: unset; 80 | align-items: unset; 81 | flex-grow: 1; 82 | } 83 | 84 | .right { 85 | flex: none; 86 | @media screen and (max-width: $sm) { 87 | width: 100%; 88 | } 89 | 90 | .availableSpace { 91 | width: 250px; 92 | @media screen and (max-width: $xs) { 93 | width: 100%; 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | .imageWrapper { 101 | position: relative; 102 | width: 100%; 103 | padding-top: 70%; 104 | background-color: $block-placeholder-color; 105 | border-radius: 8px; 106 | overflow: hidden; 107 | transform: translate(0); 108 | 109 | .progress { 110 | position: absolute; 111 | top: 0; 112 | left: 0; 113 | right: 0; 114 | bottom: 0; 115 | margin: auto; 116 | color:$paper-color; 117 | z-index: 2; 118 | } 119 | 120 | .blur { 121 | position: absolute; 122 | top: -10%; 123 | left: -10%; 124 | margin: auto; 125 | width: 120%; 126 | height: 120%; 127 | object-fit: cover; 128 | z-index: 1; 129 | filter: blur(12px); 130 | background-color: transparent; 131 | opacity: .8; 132 | } 133 | 134 | .image { 135 | position: absolute; 136 | top: 0; 137 | left: 0; 138 | width: 100%; 139 | height: 100%; 140 | object-fit: contain; 141 | background-color: transparent; 142 | z-index: 3; 143 | transform: translate(0); 144 | } 145 | } 146 | 147 | .bottomPanel { 148 | display: flex; 149 | align-items: center; 150 | gap: 12px; 151 | @media screen and (max-width: $md) { 152 | flex-direction: column-reverse; 153 | align-items: flex-start; 154 | gap: 6px; 155 | } 156 | 157 | .actions { 158 | display: flex; 159 | gap: 12px; 160 | @media screen and (max-width: $md) { 161 | width: 100%; 162 | flex-wrap: wrap; 163 | } 164 | @media screen and (max-width: $sm) { 165 | gap: 6px; 166 | 167 | button { 168 | padding: 10px; 169 | height: 30px; 170 | 171 | span { 172 | font-size: 14px; 173 | } 174 | } 175 | } 176 | } 177 | 178 | .count { 179 | margin-left: auto; 180 | margin-right: 12px; 181 | @media screen and (max-width: $md) { 182 | margin-left: unset; 183 | } 184 | } 185 | } 186 | 187 | .whiteHoleDialog { 188 | .whiteHoleDialogImages { 189 | display: flex; 190 | flex-wrap: wrap; 191 | background-color: $block-placeholder-color; 192 | border-radius: 12px; 193 | margin-top: 16px; 194 | margin-bottom: 16px; 195 | align-self: flex-start; 196 | 197 | .whiteHoleImageWrapper { 198 | position: relative; 199 | width: calc(100% / 8); 200 | padding-top: calc(100% / 8); 201 | 202 | img { 203 | position: absolute; 204 | top: 0; 205 | width: calc(100% - 12px); 206 | height: calc(100% - 12px); 207 | margin: 6px; 208 | } 209 | } 210 | } 211 | 212 | .visibility { 213 | display: flex; 214 | align-items: center; 215 | cursor: pointer; 216 | user-select: none; 217 | } 218 | } 219 | 220 | .divider { 221 | background-color: fade-out(white, .9); 222 | } 223 | 224 | .whiteHoleHeader { 225 | display: flex; 226 | gap: 24px; 227 | width: 100%; 228 | justify-content: space-between; 229 | align-items: center; 230 | @media screen and (max-width: $md) { 231 | flex-direction: column; 232 | gap: 12px; 233 | justify-content: flex-start; 234 | align-items: flex-start; 235 | } 236 | 237 | .actions { 238 | display: flex; 239 | flex-wrap: wrap; 240 | gap: 12px; 241 | } 242 | } 243 | 244 | .integrationDialog { 245 | .inputBlock { 246 | display: flex; 247 | gap: 12px; 248 | width: 100%; 249 | 250 | & > *:first-child { 251 | flex-grow: 1; 252 | } 253 | 254 | & > *:last-child { 255 | flex: none; 256 | display: flex; 257 | gap: 8px; 258 | } 259 | } 260 | 261 | .integrations { 262 | display: flex; 263 | gap: 12px; 264 | flex-wrap: wrap; 265 | margin-top: 12px; 266 | } 267 | 268 | .code { 269 | border-radius: 12px; 270 | } 271 | 272 | tt { 273 | background-color: $block-placeholder-color; 274 | padding: 0 4px; 275 | margin: 0 2px; 276 | border-radius: 4px; 277 | } 278 | } -------------------------------------------------------------------------------- /frontend/src/components/Photo/index.js: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useMemo, useRef, useState} from "react" 2 | import classnames from "classnames" 3 | import urlJoin from "url-join" 4 | import {useSnackbar} from "notistack" 5 | import copy from "copy-to-clipboard" 6 | import UAParser from "ua-parser-js" 7 | import prettyBytes from "pretty-bytes" 8 | import { 9 | copyBlobToClipboard, 10 | getBlobFromImageElement, 11 | } from "copy-image-clipboard" 12 | import {downloadFile} from "../../functions/utils" 13 | import useApi from "../../api/useApi" 14 | 15 | import Image from "../Image" 16 | import Card from "../Card" 17 | import Button from "../Button" 18 | import IconButton from "../IconButton" 19 | import Tooltip from "../Tooltip" 20 | import DoubleClick from "../DoubleClick" 21 | import {FlatIcon} from "../FlatIcon" 22 | 23 | import styles from "./index.module.scss" 24 | 25 | const parser = new UAParser() 26 | const showCopyImageButton = 27 | parser.getEngine().name === "Blink" || 28 | parser.getBrowser().name.includes("Chrome") 29 | 30 | export default function Photo(props) { 31 | const { 32 | id, 33 | url, 34 | thumbnail, 35 | drive_name, 36 | file_name, 37 | iso_date, 38 | size, 39 | extension, 40 | unix_date, 41 | isChecked, 42 | onCheck, 43 | onDelete: onDeleteProp, 44 | onZoom, 45 | isWhiteHole, 46 | whiteHoleKey, 47 | isPublic, 48 | className, 49 | ...rest 50 | } = props 51 | 52 | const src = useMemo( 53 | () => urlJoin(process.env.REACT_APP_API_BASE_URL, url), 54 | [url] 55 | ) 56 | const thumbnailSrc = useMemo( 57 | () => urlJoin(process.env.REACT_APP_API_BASE_URL, thumbnail), 58 | [thumbnail] 59 | ) 60 | useMemo(() => `thumbnail_${src}`, [src]) 61 | const {enqueueSnackbar} = useSnackbar() 62 | const {deletePhotos, deletePhotosFromWhiteHole} = useApi() 63 | 64 | const [loadingCopyImage, setLoadingCopyImage] = useState(false) 65 | const [loadingDelete, setLoadingDelete] = useState(false) 66 | const clickTimer = useRef(null) 67 | const imgRef = useRef(null) 68 | 69 | const download = useCallback(() => { 70 | downloadFile(src, drive_name) 71 | enqueueSnackbar({ 72 | variant: "default", 73 | message: "Downloading. Just a second...", 74 | }) 75 | }, [src, drive_name, enqueueSnackbar]) 76 | 77 | const copyUrl = useCallback(() => { 78 | const testUrl = urlJoin(process.env.REACT_APP_API_BASE_URL, url) 79 | if (/^https?:\/\//.test(testUrl)) { 80 | copy(testUrl) 81 | } else { 82 | copy( 83 | urlJoin( 84 | window.location.origin, 85 | process.env.REACT_APP_API_BASE_URL, 86 | url 87 | ) 88 | ) 89 | } 90 | 91 | enqueueSnackbar({ 92 | variant: "success", 93 | message: "Direct image link copied to clipboard!", 94 | }) 95 | }, [url, enqueueSnackbar]) 96 | 97 | const copyImage = useCallback(async () => { 98 | try { 99 | setLoadingCopyImage(true) 100 | let img = document.createElement("img") 101 | img.crossOrigin = "anonymous" 102 | img.src = src 103 | img.addEventListener("load", async () => { 104 | const blob = await getBlobFromImageElement(img) 105 | await copyBlobToClipboard(blob) 106 | img.remove() 107 | img = null 108 | enqueueSnackbar({ 109 | variant: "success", 110 | message: "Image copied to clipboard!", 111 | }) 112 | setLoadingCopyImage(false) 113 | }) 114 | img.addEventListener("error", e => { 115 | console.error(e) 116 | enqueueSnackbar({ 117 | variant: "error", 118 | message: "Can't copy to clipboard. Try again.", 119 | }) 120 | setLoadingCopyImage(false) 121 | }) 122 | } catch (err) { 123 | console.error(err.name, err.message) 124 | enqueueSnackbar({ 125 | variant: "error", 126 | message: "Can't copy to clipboard. Try again.", 127 | }) 128 | setLoadingCopyImage(false) 129 | } 130 | }, [enqueueSnackbar, src]) 131 | 132 | const onLoad = useCallback(img => { 133 | imgRef.current = img 134 | }, []) 135 | 136 | const onDelete = useCallback(async () => { 137 | clearTimeout(clickTimer.current) 138 | setLoadingDelete(true) 139 | const {status} = isWhiteHole 140 | ? await deletePhotosFromWhiteHole({ 141 | white_hole_key: whiteHoleKey, 142 | ids: [id], 143 | }) 144 | : await deletePhotos({ids: [id]}) 145 | setLoadingDelete(false) 146 | if (status) { 147 | onDeleteProp(id) 148 | enqueueSnackbar({ 149 | variant: "success", 150 | message: "Cool, deleted!", 151 | }) 152 | } else { 153 | enqueueSnackbar({ 154 | variant: "error", 155 | message: "Couldn't delete.", 156 | }) 157 | } 158 | }, [ 159 | isWhiteHole, 160 | deletePhotosFromWhiteHole, 161 | whiteHoleKey, 162 | id, 163 | deletePhotos, 164 | onDeleteProp, 165 | enqueueSnackbar, 166 | ]) 167 | 168 | return ( 169 | 170 |
171 | onZoom(src, thumbnailSrc)} 175 | onLoad={onLoad} 176 | /> 177 |
178 | {extension.replace(".", "").toUpperCase()}{" "} 179 | {prettyBytes(size).toUpperCase()} 180 |
181 | {!isPublic && ( 182 |
189 | {isChecked && } 190 |
191 | )} 192 |
193 | 194 | 203 |
204 | {showCopyImageButton && ( 205 | 206 | 212 | 213 | 214 | 215 | )} 216 | 217 | 218 | 219 | 220 | 221 | {!isPublic && ( 222 | 227 | 233 | 234 | 235 | 236 | )} 237 |
238 |
239 | ) 240 | } 241 | -------------------------------------------------------------------------------- /backend/src/index.js: -------------------------------------------------------------------------------- 1 | const mime = require("mime") 2 | const axios = require("axios") 3 | const express = require("express") 4 | const fileUpload = require("express-fileupload") 5 | const expressApp = express() 6 | const { 7 | savePhoto, 8 | getPhotos, 9 | getPhoto, 10 | deletePhotos, 11 | getPhotoFromBase, 12 | getWhiteHoles, 13 | getWhiteHole, 14 | createWhiteHole, 15 | deleteWhiteHole, 16 | updateWhiteHole, 17 | deletePhotosFromWhiteHole, 18 | addPhotosToWhiteHole, 19 | getIntegration, 20 | createIntegration, 21 | deleteIntegration, 22 | getIntegrations, 23 | getSettings, 24 | setSettings, 25 | searchSurfer, 26 | } = require("./db") 27 | 28 | expressApp.use(express.json()) 29 | expressApp.use(fileUpload()) 30 | 31 | expressApp.get("/", (req, res) => { 32 | res.send("Hello World!") 33 | }) 34 | 35 | expressApp.use((req, res, next) => { 36 | res.append("Access-Control-Allow-Origin", "*") 37 | res.append("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE") 38 | res.append("Access-Control-Allow-Headers", "*") 39 | next() 40 | }) 41 | 42 | expressApp.get("/photos", async (req, res) => { 43 | const photos = await getPhotos({ 44 | limit: Number(req.query.limit), 45 | offset: Number(req.query.offset), 46 | }) 47 | res.json(photos) 48 | }) 49 | 50 | expressApp.delete("/photos", async (req, res) => { 51 | const status = await deletePhotos({ids: req.body.ids}) 52 | res.send({status}) 53 | }) 54 | 55 | expressApp.post("/photo", async (req, res) => { 56 | const photo = await savePhoto(req.files.photo) 57 | res.json(photo) 58 | }) 59 | 60 | expressApp.get("/photo/:drive_name", async (req, res) => { 61 | const photo = await getPhoto({drive_name: req.params.drive_name}) 62 | if (!photo) { 63 | res.status(404).json({error: "Photo not found"}) 64 | return 65 | } 66 | res.set("Content-Type", mime.getType(req.params.drive_name)) 67 | res.send(photo) 68 | }) 69 | 70 | expressApp.get("/key/:key", async (req, res) => { 71 | const photo = await getPhotoFromBase({key: req.params.key}) 72 | if (!photo) { 73 | res.status(404).json({error: "Photo not found"}) 74 | return 75 | } 76 | res.send(photo) 77 | }) 78 | 79 | expressApp.post("/integration/:key", async (req, res) => { 80 | try { 81 | let photo 82 | if (req?.files?.photo) { 83 | photo = await savePhoto(req.files.photo) 84 | } else if (req?.body?.url) { 85 | const response = await axios({ 86 | url: req.body.url, 87 | responseType: "arraybuffer", 88 | headers: { 89 | "user-agent": 90 | req.headers["user-agent"] || 91 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36", 92 | }, 93 | }) 94 | const type = 95 | req.body.url.match(/^data:(.+?);/)?.[1] || 96 | response.headers["content-type"] || 97 | "image/jpeg" 98 | photo = await savePhoto({ 99 | name: `integration.${mime.getExtension(type)}`, 100 | size: response.data.length, 101 | data: response.data, 102 | }) 103 | } else { 104 | throw Error("No url or photo in the request") 105 | } 106 | const {white_hole_key} = await getIntegration({key: req.params.key}) 107 | const status = await addPhotosToWhiteHole({ 108 | white_hole_key, 109 | ids: [photo.key], 110 | }) 111 | res.json({ 112 | status, 113 | ...photo, 114 | }) 115 | } catch (err) { 116 | console.error(err) 117 | res.json({ 118 | status: false, 119 | error: err.toString(), 120 | }) 121 | } 122 | }) 123 | 124 | expressApp.post("/integration", async (req, res) => { 125 | res.json(await createIntegration({name: req.body.name})) 126 | }) 127 | 128 | expressApp.delete("/integration", async (req, res) => { 129 | res.json(await deleteIntegration({key: req.body.key})) 130 | }) 131 | 132 | expressApp.get("/integration", async (req, res) => { 133 | res.json(await getIntegrations()) 134 | }) 135 | 136 | expressApp.post("/download", async (req, res) => { 137 | try { 138 | const response = await axios({ 139 | url: req.body.url, 140 | responseType: "arraybuffer", 141 | headers: { 142 | "user-agent": req.headers["user-agent"] || 143 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36", 144 | }, 145 | }) 146 | const type = 147 | req.body.url.match(/^data:(.+?);/)?.[1] || 148 | response.headers["content-type"] || 149 | "image/jpeg" 150 | const photo = await savePhoto({ 151 | name: `download.${mime.getExtension(type)}`, 152 | size: response.data.length, 153 | data: response.data, 154 | }) 155 | res.json(photo) 156 | } catch (err) { 157 | console.error(err) 158 | res.status(400).json({error: "Can't download photo"}) 159 | } 160 | }) 161 | 162 | expressApp.delete("/white-hole/photos", async (req, res) => { 163 | const status = await deletePhotosFromWhiteHole({ 164 | white_hole_key: req.body.white_hole_key, 165 | ids: req.body.ids, 166 | }) 167 | res.send({status}) 168 | }) 169 | 170 | expressApp.put("/white-hole/photos", async (req, res) => { 171 | const status = await addPhotosToWhiteHole({ 172 | white_hole_key: req.body.white_hole_key, 173 | ids: req.body.ids, 174 | }) 175 | res.send({status}) 176 | }) 177 | 178 | expressApp.get("/white-hole/private/:key", async (req, res) => { 179 | const whiteHole = await getWhiteHole({ 180 | key: req.params.key, 181 | is_public: false, 182 | }) 183 | res.send(whiteHole) 184 | }) 185 | 186 | expressApp.get("/white-hole/public/:key", async (req, res) => { 187 | const whiteHole = await getWhiteHole({ 188 | key: req.params.key, 189 | is_public: true, 190 | }) 191 | res.send(whiteHole) 192 | }) 193 | 194 | expressApp.delete("/white-hole/:key", async (req, res) => { 195 | const status = await deleteWhiteHole({ 196 | key: req.params.key, 197 | }) 198 | res.send({status}) 199 | }) 200 | 201 | expressApp.post("/white-hole", async (req, res) => { 202 | const whiteHole = await createWhiteHole({ 203 | images: req.body.images, 204 | name: req.body.name, 205 | is_public: req.body.is_public, 206 | }) 207 | res.send(whiteHole) 208 | }) 209 | 210 | expressApp.get("/white-holes", async (req, res) => { 211 | const whiteHoles = await getWhiteHoles({ 212 | limit: Number(req.query.limit) || undefined, 213 | offset: Number(req.query.offset) || undefined, 214 | }) 215 | res.send(whiteHoles) 216 | }) 217 | 218 | expressApp.get("/search-surfer", async (req, res) => { 219 | res.send(await searchSurfer(req.query)) 220 | }) 221 | 222 | expressApp.get("/settings", async (req, res) => { 223 | const settings = await getSettings() 224 | res.send(settings) 225 | }) 226 | 227 | expressApp.put("/settings", async (req, res) => { 228 | try { 229 | console.log(req.body) 230 | await setSettings({ 231 | key: req.body.key, 232 | value: req.body.value, 233 | }) 234 | res.send({status: true}) 235 | } catch (err) { 236 | console.error(err) 237 | res.send({status: false}) 238 | } 239 | }) 240 | 241 | module.exports = expressApp 242 | -------------------------------------------------------------------------------- /backend/src/db.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const axios = require("axios"); 3 | const sharp = require("sharp"); 4 | const { DETA_PROJECT_KEY, DETA_SPACE_APP_HOSTNAME } = require("./env"); 5 | const { Deta } = require("deta"); 6 | const deta = Deta(DETA_PROJECT_KEY); 7 | 8 | const db = deta.Base("black-hole"); 9 | const drive = deta.Drive("black-hole"); 10 | const whiteHolesDB = deta.Base("white-holes"); 11 | const integrationsDB = deta.Base("integrations"); 12 | const settingsDB = deta.Base("settings"); 13 | 14 | const get = async (base, id) => { 15 | const { count, items } = await base.fetch({ id }); 16 | if (count === 0) { 17 | return null; 18 | } else { 19 | return items[0]; 20 | } 21 | }; 22 | 23 | const or = (arr, key) => 24 | arr.map((item) => ({ 25 | [key]: item.constructor.name === "Object" ? item[key] : item, 26 | })); 27 | 28 | const fetchAll = async (base, query = {}) => { 29 | let res = await base.fetch(query); 30 | let allItems = res.items; 31 | 32 | while (res.last) { 33 | res = await base.fetch(query, { last: res.last }); 34 | allItems = allItems.concat(res.items); 35 | } 36 | return { count: allItems.length, items: allItems }; 37 | }; 38 | 39 | const savePhoto = async (photo) => { 40 | let key; 41 | try { 42 | const extension = path.extname(photo.name).toLowerCase(); 43 | const baseItem = await db.put({ 44 | file_name: photo.name, 45 | extension, 46 | size: photo.size || 0, 47 | iso_date: new Date().toISOString(), 48 | unix_date: new Date().valueOf(), 49 | drive_name: "", 50 | url: "", 51 | }); 52 | key = baseItem.key; 53 | const drive_name = `${baseItem.key}${extension}`; 54 | await db.update( 55 | { 56 | id: key, 57 | drive_name, 58 | url: `/photo/${drive_name}`, 59 | thumbnail: `/photo/thumbnail_${drive_name}`, 60 | }, 61 | key 62 | ); 63 | const driveItem = await drive.put(`${key}${extension}`, { 64 | data: photo.data, 65 | }); 66 | 67 | let thumbnail; 68 | if (extension === ".gif") { 69 | thumbnail = await sharp(photo.data, { animated: true }) 70 | .resize({ width: 120, height: 120 }) 71 | .gif() 72 | .toBuffer(); 73 | } else if ([".svg", ".webp"].includes(extension)) { 74 | thumbnail = photo.data; 75 | } else { 76 | thumbnail = await sharp(photo.data) 77 | .resize({ width: 300, height: 200, fit: "cover", background: "white" }) 78 | .jpeg({ 79 | quality: 75, 80 | progressive: true, 81 | chromaSubsampling: "4:4:4", 82 | }) 83 | .toBuffer(); 84 | } 85 | await drive.put(`thumbnail_${key}${extension}`, { data: thumbnail }); 86 | 87 | return { 88 | key, 89 | url: `https://${DETA_SPACE_APP_HOSTNAME}/api/photo/${key}${extension}`, 90 | }; 91 | } catch (err) { 92 | console.error(err); 93 | if (key) await db.delete(key); 94 | } 95 | }; 96 | 97 | const getPhotos = async ({ limit = 10, offset = 0 }) => { 98 | const { count, items } = await fetchAll(db); 99 | items.sort((b, a) => a.unix_date - b.unix_date); 100 | const size = items.reduce((acc, item) => acc + item.size, 0); 101 | const sliced = items.slice(offset, offset + limit); 102 | return { 103 | count, 104 | items: sliced, 105 | size, 106 | next: offset + limit < count, 107 | }; 108 | }; 109 | 110 | const getPhotoFromBase = async ({ key }) => { 111 | return await db.get(key); 112 | }; 113 | 114 | const getPhoto = async ({ drive_name }) => { 115 | const img = await drive.get(drive_name); 116 | if (!img) return null; 117 | const buffer = await img.arrayBuffer(); 118 | return Buffer.from(buffer); 119 | }; 120 | 121 | const getThumbnail = async ({ drive_name }) => { 122 | const thumbnail = await drive.get(`thumbnail_${drive_name}`); 123 | const buffer = await thumbnail.arrayBuffer(); 124 | return Buffer.from(buffer); 125 | }; 126 | 127 | const deletePhotos = async ({ ids }) => { 128 | const { items } = await fetchAll(db, or(ids, "id")); 129 | for (const photo of items) { 130 | try { 131 | await db.delete(photo.key); 132 | await drive.delete(photo.drive_name); 133 | await drive.delete(`thumbnail_${photo.drive_name}`); 134 | } catch (err) { 135 | console.error(err); 136 | } 137 | } 138 | return true; 139 | }; 140 | 141 | const deletePhotosFromWhiteHole = async ({ white_hole_key, ids }) => { 142 | try { 143 | const { images } = await whiteHolesDB.get(white_hole_key); 144 | const newImages = images.filter((id) => !ids.includes(id)); 145 | await whiteHolesDB.update({ images: newImages }, white_hole_key); 146 | return true; 147 | } catch (err) { 148 | console.error(err); 149 | return false; 150 | } 151 | }; 152 | 153 | const addPhotosToWhiteHole = async ({ white_hole_key, ids }) => { 154 | try { 155 | const { images } = await whiteHolesDB.get(white_hole_key); 156 | const newImages = images.concat(ids.filter((id) => !images.includes(id))); 157 | await whiteHolesDB.update({ images: newImages }, white_hole_key); 158 | return true; 159 | } catch (err) { 160 | console.error(err); 161 | return false; 162 | } 163 | }; 164 | 165 | const getWhiteHoles = async ({ limit = 20, offset = 0 }) => { 166 | const { count, items } = await fetchAll(whiteHolesDB); 167 | items.sort((b, a) => a.unix_date - b.unix_date); 168 | const sliced = items.slice(offset, offset + limit); 169 | for (const item of sliced) { 170 | if (item.images.length) { 171 | const { items: images } = await fetchAll(db, or(item.images, "id")); 172 | item.images = images.slice(-4); 173 | } 174 | } 175 | return { 176 | count, 177 | items: sliced, 178 | next: offset + limit < count, 179 | }; 180 | }; 181 | 182 | const getWhiteHole = async ({ key, is_public: _is_public }) => { 183 | const query = { key }; 184 | _is_public && (query.is_public = true); 185 | const { items: _items } = await fetchAll(whiteHolesDB, query); 186 | if (_items.length === 0) { 187 | return { error: "DOES_NOT_EXIST" }; 188 | } 189 | const whiteHole = _items[0]; 190 | 191 | const { count, items } = whiteHole.images.length 192 | ? await fetchAll(db, or(whiteHole.images, "id")) 193 | : { count: 0, items: [] }; 194 | items.sort((b, a) => a.unix_date - b.unix_date); 195 | 196 | return { 197 | ...whiteHole, 198 | images: items, 199 | count, 200 | }; 201 | }; 202 | 203 | const createWhiteHole = async ({ name, images, is_public }) => { 204 | const { key } = await whiteHolesDB.put({ 205 | name, 206 | images, 207 | is_public, 208 | iso_date: new Date().toISOString(), 209 | unix_date: new Date().valueOf(), 210 | }); 211 | await whiteHolesDB.update( 212 | { 213 | id: key, 214 | }, 215 | key 216 | ); 217 | return { key }; 218 | }; 219 | 220 | const deleteWhiteHole = async ({ key }) => { 221 | try { 222 | await whiteHolesDB.delete(key); 223 | const { items } = await fetchAll(integrationsDB, { white_hole_key: key }); 224 | if (items.length) { 225 | await integrationsDB.delete(items[0].key); 226 | } 227 | return true; 228 | } catch (err) { 229 | console.error(err); 230 | return false; 231 | } 232 | }; 233 | 234 | const getIntegration = async ({ key }) => { 235 | const integration = await integrationsDB.get(key); 236 | if (!integration) { 237 | throw new Error("Integration doesn't exist"); 238 | } 239 | return integration; 240 | }; 241 | 242 | const createIntegration = async ({ name }) => { 243 | const { key: white_hole_key } = await createWhiteHole({ 244 | name, 245 | images: [], 246 | is_public: false, 247 | }); 248 | const { key: integration_key } = await integrationsDB.put({ 249 | name, 250 | white_hole_key, 251 | }); 252 | return { 253 | name, 254 | white_hole_key, 255 | integration_key, 256 | }; 257 | }; 258 | 259 | const deleteIntegration = async ({ key }) => { 260 | await integrationsDB.delete(key); 261 | return true; 262 | }; 263 | 264 | const getIntegrations = async () => { 265 | return await fetchAll(integrationsDB); 266 | }; 267 | 268 | const getSettings = async () => { 269 | const { items } = await fetchAll(settingsDB); 270 | const result = {}; 271 | items.forEach((items) => { 272 | result[items.key] = items.value; 273 | }); 274 | return result; 275 | }; 276 | 277 | const setSettings = async ({ key, value }) => { 278 | await settingsDB.put(value, key); 279 | }; 280 | 281 | const searchSurfer = async ({ query, results }) => { 282 | if (query === "" || !query) { 283 | return { items: [], query: "" }; 284 | } else { 285 | const { value: surfer_host } = await settingsDB.get("surfer_host"); 286 | const { value: surfer_api_key } = await settingsDB.get("surfer_api_key"); 287 | const response = await axios({ 288 | url: `https://${surfer_host}/api/search/image`, 289 | params: { query, results }, 290 | headers: { 291 | "X-Space-App-Key": surfer_api_key, 292 | }, 293 | }); 294 | return response.data; 295 | } 296 | }; 297 | 298 | module.exports = { 299 | savePhoto, 300 | getPhotos, 301 | getPhoto, 302 | deletePhotos, 303 | getPhotoFromBase, 304 | getWhiteHoles, 305 | getWhiteHole, 306 | createWhiteHole, 307 | deleteWhiteHole, 308 | deletePhotosFromWhiteHole, 309 | addPhotosToWhiteHole, 310 | getIntegration, 311 | createIntegration, 312 | deleteIntegration, 313 | getIntegrations, 314 | getSettings, 315 | setSettings, 316 | searchSurfer, 317 | }; 318 | -------------------------------------------------------------------------------- /frontend/src/components/SurferSearch/index.js: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useRef, useState} from "react" 2 | import classnames from "classnames" 3 | import useApi from "../../api/useApi" 4 | import useDialog from "../../hooks/useDialog" 5 | import {useSnackbar} from "notistack" 6 | 7 | import TextField from "../TextField" 8 | import Typography from "../Typography" 9 | import Card from "../Card" 10 | import Link from "../Link" 11 | import Image from "../Image" 12 | import {FlatIcon} from "../FlatIcon" 13 | import Collapse from "@mui/material/Collapse" 14 | import Grid from "@mui/material/Grid" 15 | import InputAdornment from "@mui/material/InputAdornment" 16 | import IconButton from "@mui/material/IconButton" 17 | import CircularProgress from "@mui/material/CircularProgress" 18 | 19 | import surferLogoImage from "../../images/integrations/surfer.png" 20 | 21 | import styles from "./index.module.scss" 22 | import Button from "../Button" 23 | 24 | export default function SurferSearch(props) { 25 | const {onSave, className, classes = {}, ...rest} = props 26 | const {searchSurfer, getSettings, setSettings} = useApi() 27 | const {enqueueSnackbar} = useSnackbar() 28 | 29 | const [render, setRender] = useState(false) 30 | const [images, setImages] = useState([]) 31 | const [query, setQuery] = useState("") 32 | const [isLoading, setIsLoading] = useState(false) 33 | 34 | const [surferHost, setSurferHost] = useState("") 35 | const [surferApiKey, setSurferApiKey] = useState("") 36 | const [isConnecting, setIsConnecting] = useState(false) 37 | 38 | const settings = useRef({}) 39 | const queryTrim = query.trim() 40 | 41 | const { 42 | open: openDialog, 43 | close: closeDialog, 44 | props: dialogProps, 45 | Component: Dialog, 46 | } = useDialog() 47 | 48 | const search = useCallback( 49 | async e => { 50 | e && e.preventDefault() 51 | if (isLoading) return 52 | if ( 53 | !settings.current.surfer_host || 54 | !settings.current.surfer_api_key 55 | ) { 56 | return openDialog() 57 | } 58 | if (queryTrim === "") { 59 | setImages([]) 60 | setIsLoading(false) 61 | return 62 | } 63 | setIsLoading(true) 64 | try { 65 | const {items} = await searchSurfer({ 66 | query: queryTrim, 67 | results: 12, 68 | }) 69 | if (!items) throw new Error("Invalid items") 70 | setImages(items) 71 | } catch (err) { 72 | console.error(err) 73 | setImages([]) 74 | enqueueSnackbar({ 75 | variant: "error", 76 | message: ( 77 | <> 78 | Couldn't search.{" "} 79 | 80 | Click here 81 | {" "} 82 | to update your Surfer settings. 83 | 84 | ), 85 | }) 86 | } 87 | setIsLoading(false) 88 | }, 89 | [enqueueSnackbar, openDialog, queryTrim, searchSurfer, isLoading] 90 | ) 91 | 92 | const clear = useCallback(() => { 93 | setImages([]) 94 | setIsLoading(false) 95 | setQuery("") 96 | document.getElementById("surfer-input").focus() 97 | setTimeout(() => {}) 98 | }, []) 99 | 100 | const onFocus = useCallback( 101 | e => { 102 | if ( 103 | !settings.current.surfer_host || 104 | !settings.current.surfer_api_key 105 | ) { 106 | openDialog() 107 | e.target.blur() 108 | } 109 | }, 110 | [openDialog] 111 | ) 112 | 113 | const save = useCallback( 114 | image => { 115 | onSave({ 116 | type: "url", 117 | data: image.image, 118 | }) 119 | }, 120 | [onSave] 121 | ) 122 | 123 | const connect = useCallback(async () => { 124 | const surferHostTrim = surferHost 125 | .trim() 126 | .replace(/^https?:\/\//g, "") 127 | .replace(/\//g, "") 128 | const surferApiKeyTrim = surferApiKey.trim() 129 | if (surferHostTrim === "" || surferApiKeyTrim === "") return 130 | 131 | setIsConnecting(true) 132 | try { 133 | const {status: s0} = await setSettings({ 134 | key: "surfer_host", 135 | value: surferHostTrim, 136 | }) 137 | const {status: s1} = await setSettings({ 138 | key: "surfer_api_key", 139 | value: surferApiKeyTrim, 140 | }) 141 | if (!s0 || !s1) { 142 | throw Error("Settings was not saved") 143 | } 144 | settings.current = { 145 | surfer_api_key: surferApiKeyTrim, 146 | surfer_host: surferHostTrim, 147 | } 148 | closeDialog() 149 | enqueueSnackbar({ 150 | variant: "success", 151 | message: "Surfer successfully connected!", 152 | }) 153 | } catch (err) { 154 | console.error(err) 155 | enqueueSnackbar({ 156 | variant: "error", 157 | message: "Error occurred. Try again later.", 158 | }) 159 | } 160 | setIsConnecting(false) 161 | }, [closeDialog, enqueueSnackbar, setSettings, surferHost, surferApiKey]) 162 | 163 | useEffect(() => { 164 | if (queryTrim === "") { 165 | setImages([]) 166 | setIsLoading(false) 167 | } 168 | }, [queryTrim]) 169 | 170 | useEffect(() => { 171 | ;(async () => { 172 | settings.current = await getSettings() 173 | setRender(true) 174 | })() 175 | }, [getSettings]) 176 | 177 | return render ? ( 178 | <> 179 | 191 | Close 192 | , 193 | , 203 | ]} 204 | > 205 | 206 | Connect Surfer 207 | 208 | 213 | This is one-time action. Then you'll be able to use Surfer 214 | from your Black Hole. 215 | 216 |
217 | 223 | 1. Open your Canvas 224 |
225 | 2. Click to the three dots on the Surfer app 226 |
227 | 3. Go to Settings 228 |
229 | 4. Go to API Keys tab 230 |
231 | 5. Create new API key 232 |
233 | 6. Paste it in the field below 234 |
235 | setSurferApiKey(e.target.value)} 239 | /> 240 |
241 | 247 | 1. Open Surfer from your Canvas 248 |
249 | 2. Copy the url from the address bar 250 |
251 | 3. Paste it in the field below 252 |
253 | setSurferHost(e.target.value)} 257 | /> 258 |
259 | 260 |
264 |
265 | setQuery(e.target.value)} 271 | InputProps={{ 272 | endAdornment: 273 | query.length > 0 ? ( 274 | 275 | 279 | 283 | 284 | 285 | ) : null, 286 | }} 287 | /> 288 | 299 | 300 | 301 | 302 | 307 | API provided by{" "} 308 | 316 | Surfer 317 | 318 | 319 | 320 | {images.map(image => ( 321 | 328 |
save(image)} 331 | > 332 |
333 | 337 | Click to save 338 | 339 |
340 | {image.title} 347 |
348 |
349 | ))} 350 |
351 |
352 |
353 |
354 | 355 | ) : null 356 | } 357 | -------------------------------------------------------------------------------- /frontend/src/App/index.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useCallback} from "react" 2 | import useApi from "../api/useApi" 3 | import copy from "copy-to-clipboard" 4 | import {Route, Routes, Navigate, useNavigate, useMatch} from "react-router-dom" 5 | import useDialog from "../hooks/useDialog" 6 | import {CopyBlock, monokai} from "react-code-blocks" 7 | import {useSnackbar} from "notistack" 8 | import {numberWithSpaces} from "../functions/utils" 9 | import urlJoin from "url-join" 10 | 11 | import Container from "@mui/material/Container" 12 | import Grid from "@mui/material/Grid" 13 | import Typography from "../components/Typography" 14 | import Photo from "../components/Photo" 15 | import FileUploadField from "../components/FileUploadField" 16 | import Link from "../components/Link" 17 | import Switch from "../components/Switch" 18 | import BottomPanel from "../components/BottomPanel" 19 | import DoubleClick from "../components/DoubleClick" 20 | import Image from "../components/Image" 21 | import WhiteHole from "../components/WhiteHole" 22 | import Button from "../components/Button" 23 | import TextField from "../components/TextField" 24 | import IntegrationTemplate from "../components/IntegrationTemplate" 25 | import Tabs from "../components/Tabs" 26 | import Tab from "../components/Tab" 27 | import SurferSearch from "../components/SurferSearch" 28 | import AvailableSpace from "../components/AvailableSpace" 29 | import GitHubButton from "react-github-btn" 30 | import CircularProgress from "@mui/material/CircularProgress" 31 | import IconButton from "../components/IconButton" 32 | import {createFlatIcon, FlatIcon} from "../components/FlatIcon" 33 | 34 | import logo from "../images/android-chrome-192x192.png" 35 | 36 | import styles from "./index.module.scss" 37 | import {Divider} from "@mui/material" 38 | 39 | const App = () => { 40 | const { 41 | getPhotos, 42 | getSinglePhoto, 43 | createWhiteHole, 44 | getWhiteHoles, 45 | getPrivateWhiteHole, 46 | getPublicWhiteHole, 47 | deletePhotosFromWhiteHole, 48 | addPhotosToWhiteHole, 49 | deleteWhiteHole, 50 | deletePhotos, 51 | createIntegration, 52 | deleteIntegration, 53 | getIntegrations, 54 | } = useApi() 55 | const navigate = useNavigate() 56 | const {enqueueSnackbar} = useSnackbar() 57 | 58 | const privateWhiteHoleMatch = useMatch("/wh/:visibility/:id") 59 | const whiteHoleId = privateWhiteHoleMatch?.params?.id 60 | const whiteHoleVisibility = privateWhiteHoleMatch?.params?.visibility 61 | 62 | /*const [user, setUser] = useState({ 63 | id: 1, 64 | })*/ 65 | const [droppedFiles, setDroppedFiles] = useState([]) 66 | const [photos, setPhotos] = useState([]) 67 | const [total, setTotal] = useState(0) 68 | const [deletedKeys, setDeletedKeys] = useState([]) 69 | const [dialogPhotoSrc, setDialogPhotoSrc] = useState("") 70 | const [dialogThumbnailSrc, setDialogThumbnailSrc] = useState("") 71 | const [isLoadingImage, setIsLoadingImage] = useState(false) 72 | const [isLoading, setIsLoading] = useState(false) 73 | const [link, setLink] = useState("") 74 | const [showLink, setShowLink] = useState(false) 75 | const [takenStorage, setTakenStorage] = useState(0) 76 | const [checkedImages, setCheckedImages] = useState([]) 77 | const [whiteHoles, setWhiteHoles] = useState([]) 78 | const [isPublicWhiteHole, setIsPublicWhiteHole] = useState(false) 79 | const [isLoadingCreateWhiteHole, setIsLoadingCreateWhiteHole] = 80 | useState(false) 81 | const [whiteHoleName, setWhiteHoleName] = useState("") 82 | const [totalWhiteHoles, setTotalWhiteHoles] = useState(0) 83 | const [whiteHole, setWhiteHole] = useState({}) 84 | const [isLoadingDelete, setIsLoadingDelete] = useState(false) 85 | const [selectedWhiteHoleKey, setSelectedWhiteHoleKey] = useState(null) 86 | const [isLoadingAddToWhiteHole, setIsLoadingAddToWhiteHole] = 87 | useState(false) 88 | 89 | const [isLoadingCreateIntegration, setIsLoadingCreateIntegration] = 90 | useState(false) 91 | const [integrations, setIntegrations] = useState([]) 92 | const [integrationTab, setIntegrationTab] = useState("create") 93 | const [integrationLink, setIntegrationLink] = useState("") 94 | const [integrationName, setIntegrationName] = useState("") 95 | 96 | const { 97 | open: openPhotoDialog, 98 | close: closePhotoDialog, 99 | props: photoDialogProps, 100 | Component: PhotoDialog, 101 | } = useDialog() 102 | const { 103 | open: openWhiteHoleDialog, 104 | close: closeWhiteHoleDialog, 105 | props: whiteHoleDialogProps, 106 | Component: WhiteHoleDialog, 107 | } = useDialog() 108 | const { 109 | open: openAddToWhiteHoleDialog, 110 | close: closeAddToWhiteHoleDialog, 111 | props: addToWhiteHoleDialogProps, 112 | Component: AddToWhiteHoleDialog, 113 | } = useDialog() 114 | const { 115 | open: openIntegrationDialog, 116 | close: closeIntegrationDialog, 117 | props: integrationDialogProps, 118 | Component: IntegrationDialog, 119 | } = useDialog() 120 | 121 | const updateLibraryInfo = useCallback(async () => { 122 | try { 123 | const {count, size} = await getPhotos({limit: 0, offset: 0}) 124 | setTakenStorage(size) 125 | setTotal(count) 126 | } catch (err) { 127 | console.error(err) 128 | } 129 | }, [getPhotos]) 130 | 131 | const onCheck = useCallback(photo => { 132 | setCheckedImages(prev => 133 | prev.includes(photo) 134 | ? prev.filter(item => item !== photo) 135 | : prev.concat(photo) 136 | ) 137 | }, []) 138 | 139 | const onDropFiles = useCallback(files => { 140 | setDroppedFiles(prev => prev.concat(files)) 141 | window.scrollTo({top: 0, behavior: "smooth"}) 142 | }, []) 143 | 144 | const zoomPhoto = useCallback( 145 | (src, thumbnail) => { 146 | setIsLoadingImage(true) 147 | setDialogPhotoSrc(src) 148 | setDialogThumbnailSrc(thumbnail || src) 149 | openPhotoDialog() 150 | }, 151 | [openPhotoDialog] 152 | ) 153 | 154 | const onFinish = useCallback( 155 | async (key, file) => { 156 | try { 157 | const photo = await getSinglePhoto({key}) 158 | setPhotos(prev => [photo, ...prev]) 159 | setTotal(prev => prev + 1) 160 | setDroppedFiles(prev => prev.filter(item => item !== file)) 161 | await updateLibraryInfo() 162 | } catch (err) { 163 | console.error(err) 164 | } 165 | }, 166 | [getSinglePhoto, updateLibraryInfo] 167 | ) 168 | 169 | const uploadLink = useCallback(() => { 170 | setLink("") 171 | onDropFiles([ 172 | { 173 | type: "url", 174 | data: link, 175 | }, 176 | ]) 177 | }, [link, onDropFiles]) 178 | 179 | const onDeletePhoto = useCallback( 180 | async key => { 181 | setTotal(prev => prev - 1) 182 | setDeletedKeys(prev => prev.concat(key)) 183 | await updateLibraryInfo() 184 | }, 185 | [updateLibraryInfo] 186 | ) 187 | 188 | const onDeletePhotoFromWhiteHole = useCallback(async key => { 189 | setWhiteHole(prev => ({ 190 | ...prev, 191 | images: prev.images.filter(image => image.id !== key), 192 | })) 193 | }, []) 194 | 195 | const onDeletePhotosFromWhiteHole = useCallback(async () => { 196 | setIsLoadingDelete(true) 197 | try { 198 | const ids = checkedImages.map(items => items.id) 199 | await deletePhotosFromWhiteHole({ 200 | ids, 201 | white_hole_key: whiteHoleId, 202 | }) 203 | setWhiteHole(prev => ({ 204 | ...prev, 205 | images: prev.images.filter( 206 | image => !ids.some(id => image.id === id) 207 | ), 208 | })) 209 | setCheckedImages([]) 210 | enqueueSnackbar({ 211 | variant: "success", 212 | message: "Successfully deleted!", 213 | }) 214 | } catch (err) { 215 | console.error(err) 216 | } 217 | setIsLoadingDelete(false) 218 | }, [checkedImages, deletePhotosFromWhiteHole, enqueueSnackbar, whiteHoleId]) 219 | 220 | const onOpenWhiteHoleDialog = useCallback(() => { 221 | setIsLoadingCreateWhiteHole(false) 222 | setWhiteHoleName("") 223 | setIsPublicWhiteHole(false) 224 | openWhiteHoleDialog() 225 | }, [openWhiteHoleDialog]) 226 | 227 | const onOpenAddToWhiteHoleDialog = useCallback(() => { 228 | setIsLoadingAddToWhiteHole(false) 229 | openAddToWhiteHoleDialog() 230 | }, [openAddToWhiteHoleDialog]) 231 | 232 | const onCreateWhiteHole = useCallback(async () => { 233 | setIsLoadingCreateWhiteHole(true) 234 | try { 235 | await createWhiteHole({ 236 | images: checkedImages.map(item => item.id), 237 | is_public: isPublicWhiteHole, 238 | name: whiteHoleName.trim(), 239 | }) 240 | const {items} = await getWhiteHoles({limit: 1, offset: 0}) 241 | setWhiteHoles(prev => [items[0], ...prev]) 242 | } catch (err) { 243 | console.error(err) 244 | } 245 | closeWhiteHoleDialog() 246 | setIsLoadingCreateWhiteHole(false) 247 | setCheckedImages([]) 248 | enqueueSnackbar({ 249 | variant: "success", 250 | message: "Your White Hole has been created!", 251 | }) 252 | }, [ 253 | createWhiteHole, 254 | closeWhiteHoleDialog, 255 | checkedImages, 256 | isPublicWhiteHole, 257 | whiteHoleName, 258 | enqueueSnackbar, 259 | getWhiteHoles, 260 | ]) 261 | 262 | const onAddToWhiteHole = useCallback( 263 | async key => { 264 | setSelectedWhiteHoleKey(key) 265 | setIsLoadingAddToWhiteHole(true) 266 | try { 267 | await addPhotosToWhiteHole({ 268 | ids: checkedImages.map(item => item.id), 269 | white_hole_key: key, 270 | }) 271 | } catch (err) { 272 | console.error(err) 273 | } 274 | closeWhiteHoleDialog() 275 | setIsLoadingAddToWhiteHole(false) 276 | setCheckedImages([]) 277 | setSelectedWhiteHoleKey(null) 278 | closeAddToWhiteHoleDialog() 279 | enqueueSnackbar({ 280 | variant: "success", 281 | message: "Photos have been added!", 282 | }) 283 | }, 284 | [ 285 | closeWhiteHoleDialog, 286 | enqueueSnackbar, 287 | addPhotosToWhiteHole, 288 | checkedImages, 289 | closeAddToWhiteHoleDialog, 290 | ] 291 | ) 292 | 293 | const onDeletePhotos = useCallback(async () => { 294 | setIsLoadingDelete(true) 295 | try { 296 | const ids = checkedImages.map(items => items.id) 297 | await deletePhotos({ 298 | ids, 299 | }) 300 | setDeletedKeys(prev => prev.concat(ids)) 301 | setCheckedImages(prev => 302 | prev.filter(item => !ids.includes(item.key)) 303 | ) 304 | enqueueSnackbar({ 305 | variant: "success", 306 | message: "Successfully deleted!", 307 | }) 308 | } catch (err) { 309 | console.error(err) 310 | } 311 | setIsLoadingDelete(false) 312 | }, [checkedImages, deletePhotos, enqueueSnackbar]) 313 | 314 | const copyWhiteHoleUrl = useCallback(() => { 315 | copy(window.location.href.replace("private", "public")) 316 | 317 | enqueueSnackbar({ 318 | variant: "success", 319 | message: "White Hole public link copied to clipboard!", 320 | }) 321 | }, [enqueueSnackbar]) 322 | 323 | const onDeleteWhiteHole = useCallback( 324 | async key => { 325 | setWhiteHoles(prev => prev.filter(items => items.key !== key)) 326 | navigate("/") 327 | enqueueSnackbar({ 328 | variant: "success", 329 | message: "White Hole has been deleted!", 330 | }) 331 | await deleteWhiteHole({key}) 332 | }, 333 | [deleteWhiteHole, enqueueSnackbar, navigate] 334 | ) 335 | 336 | const onCreateIntegration = useCallback(async () => { 337 | setIsLoadingCreateIntegration(true) 338 | const {integration_key} = await createIntegration({ 339 | name: integrationName, 340 | }) 341 | setIntegrationLink( 342 | urlJoin( 343 | window.location.origin, 344 | process.env.REACT_APP_API_BASE_URL, 345 | "integration", 346 | integration_key 347 | ) 348 | ) 349 | setIsLoadingCreateIntegration(false) 350 | }, [createIntegration, integrationName]) 351 | 352 | const onDeleteIntegration = useCallback( 353 | async key => { 354 | setIntegrations(prev => prev.filter(item => item.key !== key)) 355 | await deleteIntegration({key}) 356 | }, 357 | [deleteIntegration] 358 | ) 359 | 360 | useEffect(() => { 361 | if (whiteHoleVisibility === "public") return 362 | let blockListener = false 363 | let hasNext = true 364 | let offset = 0 365 | const limit = 30 366 | 367 | const onScroll = async force => { 368 | const toBottom = 369 | document.documentElement.scrollHeight - 370 | (document.documentElement.scrollTop + window.innerHeight) 371 | if ( 372 | (force === true && !blockListener) || 373 | (toBottom < 100 && !blockListener && hasNext) 374 | ) { 375 | blockListener = true 376 | if (offset > 0) setIsLoading(true) 377 | const {count, items, next, size} = await getPhotos({ 378 | limit, 379 | offset, 380 | }) 381 | setIsLoading(false) 382 | if (!items) return 383 | setTakenStorage(size) 384 | hasNext = next 385 | 386 | setPhotos(prev => { 387 | return prev.concat( 388 | items.filter( 389 | item => 390 | !prev.some( 391 | prevItem => prevItem.key === item.key 392 | ) 393 | ) 394 | ) 395 | }) 396 | setTotal(count) 397 | blockListener = false 398 | offset += limit 399 | } 400 | } 401 | onScroll(true) 402 | 403 | window.addEventListener("scroll", onScroll) 404 | 405 | return () => window.removeEventListener("scroll", onScroll) 406 | }, [getPhotos, whiteHoleVisibility]) 407 | 408 | useEffect(() => { 409 | if (whiteHoleVisibility === "public" || isLoadingCreateIntegration) 410 | return 411 | ;(async () => { 412 | const {items} = await getIntegrations() 413 | items.forEach( 414 | item => 415 | (item.url = urlJoin( 416 | window.location.origin, 417 | process.env.REACT_APP_API_BASE_URL, 418 | "integration", 419 | item.key 420 | )) 421 | ) 422 | setIntegrations(items) 423 | })() 424 | }, [getIntegrations, whiteHoleVisibility, isLoadingCreateIntegration]) 425 | 426 | useEffect(() => { 427 | if (whiteHoleVisibility === "public" || isLoadingCreateIntegration) 428 | return 429 | ;(async function loop({limit, offset}) { 430 | const {count, items, next} = await getWhiteHoles({limit, offset}) 431 | setTotalWhiteHoles(count) 432 | setWhiteHoles(prev => 433 | prev.concat( 434 | items.filter( 435 | item => 436 | !prev.some(prevItem => prevItem.key === item.key) 437 | ) 438 | ) 439 | ) 440 | 441 | if (next) { 442 | offset += limit 443 | await loop({limit, offset}) 444 | } 445 | })({limit: 20, offset: 0}) 446 | }, [getWhiteHoles, whiteHoleVisibility, isLoadingCreateIntegration]) 447 | 448 | useEffect(() => { 449 | if (whiteHoleVisibility !== "private") return 450 | ;(async () => { 451 | const whiteHole = await getPrivateWhiteHole({ 452 | key: whiteHoleId, 453 | }) 454 | if (whiteHole.error) { 455 | return setWhiteHole({ 456 | error: "This White Hole does not exist 😞", 457 | }) 458 | } 459 | setWhiteHole(whiteHole) 460 | })() 461 | }, [getPrivateWhiteHole, whiteHoleId, whiteHoleVisibility]) 462 | 463 | useEffect(() => { 464 | if (whiteHoleVisibility !== "public") return 465 | ;(async () => { 466 | const whiteHole = await getPublicWhiteHole({ 467 | key: whiteHoleId, 468 | }) 469 | if (whiteHole.error) { 470 | return setWhiteHole({ 471 | error: "This White Hole does not exist 😞", 472 | }) 473 | } 474 | setWhiteHole(whiteHole) 475 | })() 476 | }, [ 477 | getPrivateWhiteHole, 478 | getPublicWhiteHole, 479 | whiteHoleId, 480 | whiteHoleVisibility, 481 | ]) 482 | 483 | useEffect(() => { 484 | if (!whiteHoleId) { 485 | setWhiteHole({}) 486 | setCheckedImages([]) 487 | } 488 | }, [whiteHoleId]) 489 | 490 | /*useEffect(() => { 491 | let timeout 492 | ;(async function load() { 493 | try { 494 | const {count, size} = await getPhotos({limit: 0, offset: 0}) 495 | setTakenStorage(size) 496 | setTotal(count) 497 | } catch (err) { 498 | console.error(err) 499 | } 500 | setTimeout(load, 5000) 501 | })() 502 | 503 | return () => clearTimeout(timeout) 504 | }, [getPhotos])*/ 505 | 506 | return ( 507 | /**/ <> 513 | 523 | Close 524 | , 525 | ]} 526 | > 527 |
528 | {isLoadingImage && ( 529 | 533 | )} 534 | setIsLoadingImage(false)} 538 | /> 539 | 540 |
541 |
542 | 543 | 555 | Close 556 | , 557 | ]} 558 | > 559 | 560 | Add to White Hole 561 | 562 | 567 | Choose a White Hole to add to: 568 | 569 |
570 | 571 | {whiteHoles.map(whiteHole => ( 572 | 573 | onAddToWhiteHole(whiteHole.key)} 582 | /> 583 | 584 | ))} 585 | 586 |
587 | {checkedImages.map(image => ( 588 |
592 | 598 |
599 | ))} 600 |
601 |
602 | 603 | 615 | Close 616 | , 617 | ]} 618 | > 619 | 620 | Create an Integration 621 | 622 | 628 | Integrations allow you to save images from other apps to 629 | your White Holes. 630 | 631 | setIntegrationTab(tab)} 634 | > 635 | 636 | 637 | 638 | 639 |
640 | {integrationTab === "create" && ( 641 | <> 642 | {integrationLink && ( 643 |
644 | {}} 648 | /> 649 | { 652 | copy(integrationLink) 653 | setIntegrationLink("") 654 | setIntegrationName("") 655 | }} 656 | > 657 | 658 | 659 |
660 | )} 661 | {!integrationLink && ( 662 | <> 663 |
664 | 668 | setIntegrationName(e.target.value) 669 | } 670 | /> 671 | 682 |
683 |
684 | 688 | Integration templates: 689 | 690 |
691 | {[ 692 | { 693 | name: "Surfer", 694 | image: require("../images/integrations/surfer.png"), 695 | integrationName: 696 | "Saved from Surfer", 697 | onClick: function () { 698 | setIntegrationName( 699 | "Saved from Surfer" 700 | ) 701 | }, 702 | }, 703 | ].map(item => ( 704 | 714 | ))} 715 |
716 | 717 | )} 718 | 719 | )} 720 | {integrationTab === "manage" && ( 721 | <> 722 | {integrations.length === 0 && ( 723 | 727 | You haven't created any Integrations yet 728 | 729 | )} 730 | {integrations.map(item => ( 731 |
732 |
733 | {}} 737 | /> 738 |
739 | copy(item.url)} 742 | > 743 | 744 | 745 | 748 | onDeleteIntegration(item.key) 749 | } 750 | > 751 | 752 | 753 |
754 |
755 |
756 |
757 | ))} 758 | 759 | )} 760 | {integrationTab === "devs" && ( 761 | <> 762 | 763 | You can save images to your White Hole using 764 | Integrations API. Just POST the url of an image or 765 | send the file using multipart/form-data to 766 | your Integration link. 767 | 768 |
769 | 774 | Fetch API request example: 775 | 776 | response.json()) 783 | .then((data) => { 784 | const {status, error, url} = data; 785 | if (error) { 786 | return alert(error) 787 | } 788 | alert("Success! Direct image url: " + url) 789 | })`} 790 | language={"javascript"} 791 | theme={monokai} 792 | /> 793 |
794 | 795 | 800 | 801 | Or post multipart/form-data with Fetch 802 | API: 803 | 804 | 805 | 806 | 809 | 810 | //js 811 | const formData = new FormData() 812 | formData.append("photo", document.getElementById("photo").files[0]) 813 | 814 | fetch(your_integration_url, { 815 | method: "POST", 816 | body: formData 817 | }) 818 | .then((response) => response.json()) 819 | .then((data) => { 820 | const {status, error, url} = data; 821 | if (error) { 822 | return alert(error) 823 | } 824 | alert("Success! Direct image url: " + url) 825 | })`} 826 | language={"javascript"} 827 | theme={monokai} 828 | className={styles.code} 829 | /> 830 | 831 | )} 832 |
833 | 834 | 846 | Close 847 | , 848 | , 861 | ]} 862 | > 863 | 864 | Create White Hole 865 | 866 | 871 | With Holes allow you to group and publicly share your 872 | images. Your White Holes will be listed in your Black Hole. 873 | 874 |
875 | setWhiteHoleName(e.target.value)} 879 | /> 880 |
881 | {checkedImages.map(image => ( 882 |
886 | 892 |
893 | ))} 894 |
895 |
setIsPublicWhiteHole(prev => !prev)} 898 | > 899 | 900 | Make it public 901 | 902 | {"  "} 903 | {}} checked={isPublicWhiteHole} /> 904 |
905 |
906 | 907 | 0} 909 | className={styles.bottomPanel} 910 | > 911 |
912 | {!whiteHoleId && ( 913 | 920 | )} 921 | {!whiteHoleId && whiteHoles.length > 0 && ( 922 | 929 | )} 930 | 937 | 938 | 947 | 955 | 956 |
957 | 962 | Selected:{" "} 963 | 968 | {numberWithSpaces(checkedImages.length)} 969 | 970 | 971 |
972 | 973 | 974 |
975 | 976 | {whiteHoleVisibility === "public" ? ( 977 | 985 | {"logo"} 990 | Deta Black Hole 991 | 992 | ) : ( 993 | 994 | {"logo"} 999 | Deta Black Hole 1000 | 1001 | )} 1002 | 1003 | 1011 | Star 1012 | 1013 |
1014 | 1015 | 1016 | 1017 | 1021 |
1022 | 1023 |
1024 | {whiteHole.name && ( 1025 | <> 1026 |
1031 | 1032 | {whiteHole.name} 1033 | 1034 |
1035 | {whiteHole.is_public && ( 1036 | 1048 | )} 1049 | {whiteHoleVisibility === 1050 | "private" && ( 1051 | 1053 | onDeleteWhiteHole( 1054 | whiteHole.key 1055 | ) 1056 | } 1057 | message={ 1058 | "Double-click to delete White Hole" 1059 | } 1060 | component={"div"} 1061 | > 1062 | 1074 | 1075 | )} 1076 |
1077 |
1078 |
1079 | 1080 | {(whiteHole.images || []).map( 1081 | photo => ( 1082 | 1089 | 1099 | onCheck( 1100 | photo 1101 | ) 1102 | } 1103 | isChecked={checkedImages.includes( 1104 | photo 1105 | )} 1106 | isPublic={ 1107 | whiteHoleVisibility === 1108 | "public" 1109 | } 1110 | isWhiteHole 1111 | whiteHoleKey={ 1112 | whiteHole.key 1113 | } 1114 | /> 1115 | 1116 | ) 1117 | )} 1118 | 1119 | 1120 | )} 1121 | {!whiteHole.name && ( 1122 | 1128 | {whiteHole.error 1129 | ? whiteHole.error 1130 | : "Loading..."} 1131 | 1132 | )} 1133 | 1134 | } 1135 | /> 1136 | } /> 1137 | 1138 | 1139 | 1143 | 1149 | 1157 | Source 1158 | {" "} 1159 | /{" "} 1160 | 1166 | Author 1167 | {" "} 1168 | /{" "} 1169 | 1177 | Projects 1178 | 1179 | 1180 |
1181 | 1190 | 1191 |
1192 | {!showLink && ( 1193 | setShowLink(true)} 1197 | > 1198 | ...or upload via URL 1199 | 1200 | )} 1201 | {showLink && ( 1202 | <> 1203 | 1207 | setLink(e.target.value) 1208 | } 1209 | /> 1210 | 1219 | 1220 | )} 1221 |
1222 |
1223 |
1224 | 1225 |
1226 |
1227 | 1231 | Your White Holes 1232 | 1233 | {totalWhiteHoles > 0 && ( 1234 | 1238 | There{" "} 1239 | {totalWhiteHoles === 1 1240 | ? "is" 1241 | : "are"}{" "} 1242 | {totalWhiteHoles}{" "} 1243 | {totalWhiteHoles === 1 1244 | ? "White Hole" 1245 | : "White Holes"}{" "} 1246 | in your Black Hole 1247 | 1248 | )} 1249 | {totalWhiteHoles === 0 && ( 1250 | 1254 | Your don't have any White Holes 1255 | yet 1256 | 1257 | )} 1258 |
1259 |
1260 | 1270 |
1271 |
1272 |
1273 | 1274 | {whiteHoles.map(whiteHole => ( 1275 | 1282 | 1286 | 1287 | ))} 1288 | 1289 |
1290 |
1291 | 1292 |
1293 |
1294 | 1298 | Your photos 1299 | 1300 | {total > 0 && ( 1301 | 1305 | There{" "} 1306 | {total === 1 ? "is" : "are"}{" "} 1307 | {total}{" "} 1308 | {total === 1 1309 | ? "photo" 1310 | : "photos"}{" "} 1311 | in your Black Hole 1312 | 1313 | )} 1314 | {total === 0 && ( 1315 | 1319 | Your Black Hole is empty. Let's 1320 | drop some photos in it! 1321 | 1322 | )} 1323 |
1324 |
1325 | 1329 |
1330 |
1331 |
1332 | 1333 | {photos.map(photo => 1334 | deletedKeys.includes( 1335 | photo.key 1336 | ) ? null : ( 1337 | 1344 | 1350 | onCheck(photo) 1351 | } 1352 | isChecked={checkedImages.includes( 1353 | photo 1354 | )} 1355 | /> 1356 | 1357 | ) 1358 | )} 1359 | 1360 | {isLoading && ( 1361 | <> 1362 |
1363 |
1364 | 1370 | Loading... 1371 | 1372 | 1373 | )} 1374 | 1375 | } 1376 | /> 1377 | 1378 | } /> 1379 |
1380 |
1381 | 1382 | /*
*/ 1383 | ) 1384 | } 1385 | 1386 | export default App 1387 | --------------------------------------------------------------------------------