├── .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 | 
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 |
26 | {isLoading ? (
27 |
32 | ) : (
33 | {children}
34 | )}
35 |
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 |
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 |
39 | {IconBefore && }
40 | {!loadingText && (
41 |
46 | )}
47 |
54 | {loadingText ? (isLoading ? loadingText : children) : children}
55 |
56 | {!(loadingText && isLoading) && IconAfter && (
57 |
58 | )}
59 |
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 | Your browser isn't supported, or you turned off JavaScript.
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 |
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 |
44 | {label}
45 |
46 |
92 | }
93 | {...rest}
94 | />
95 | {helperText && (
96 |
101 | {helperText}
102 |
103 | )}
104 |
105 | )
106 | }
107 |
108 | export default Select_
109 |
--------------------------------------------------------------------------------
/frontend/src/components/Button/index.module.scss:
--------------------------------------------------------------------------------
1 | @import "../../styles/variables.scss";
2 |
3 | .root {
4 | height: 54px;
5 | padding: 17px 40px;
6 | font-weight: 500;
7 | display: flex;
8 | align-items: center;
9 | justify-content: center;
10 | font-family: inherit;
11 | overflow: hidden;
12 | user-select: none;
13 | white-space: nowrap;
14 | border-radius: 8px;
15 | outline: none;
16 | transition: $transition;
17 | &:hover {
18 | background-color: transparent;
19 | }
20 | &[disabled] {
21 | opacity: 0.6;
22 | filter: saturate(0.9) contrast(0.6);
23 | pointer-events: none;
24 | }
25 | &.small {
26 | height: 44px;
27 | padding: 12px 20px;
28 | }
29 | &.tiny {
30 | height: 32px;
31 | padding: 8px 12px;
32 | font-size: 14px;
33 | }
34 | &.fullWidth {
35 | width: 100%;
36 | }
37 | &.loading {
38 | pointer-events: none;
39 |
40 | .preloader {
41 | opacity: 1;
42 | }
43 | .iconBefore,
44 | .iconAfter,
45 | .label {
46 | opacity: 0;
47 | }
48 | }
49 |
50 | &.primary {
51 | background-color: $accent-color;
52 | color: white;
53 | .preloader {
54 | color: white;
55 | }
56 |
57 | &:not([disabled]):hover {
58 | background-color: $hover-color;
59 | }
60 | }
61 |
62 | &.secondary {
63 | background-color: fade-out($accent-color, 0.9);
64 | color: $hover-color;
65 | transition: $transition;
66 | .preloader {
67 | color: $hover-color;
68 | }
69 |
70 | &:not([disabled]):hover {
71 | background-color: fade-out($accent-color, 0.8);
72 | }
73 | }
74 |
75 | &.no-border {
76 | color: $accent-color;
77 | .preloader {
78 | color: $accent-color;
79 | }
80 |
81 | &:not([disabled]):hover {
82 | color: $hover-color;
83 | .preloader {
84 | color: $hover-color;
85 | }
86 | }
87 | }
88 |
89 | &.negative {
90 | background-color: $error-color;
91 | color: #ffffff;
92 | //box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
93 | }
94 | &.positive {
95 | background-color: $positive-color;
96 | color: #ffffff;
97 | //box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
98 | }
99 |
100 | @mixin icon {
101 | position: relative;
102 | font-size: inherit;
103 | width: 20px;
104 | height: 20px;
105 | flex: none;
106 | color: inherit;
107 | margin-top: 1px;
108 | z-index: 2;
109 | }
110 | .iconBefore {
111 | @include icon;
112 | margin-right: 8px;
113 | }
114 | .iconAfter {
115 | @include icon;
116 | margin-left: 8px;
117 | }
118 |
119 | .preloader {
120 | position: absolute;
121 | height: 20px;
122 | width: 20px;
123 | top: 0;
124 | left: 0;
125 | right: 0;
126 | bottom: 0;
127 | margin: auto;
128 | color: inherit;
129 | transition: $transition;
130 | opacity: 0;
131 | z-index: 2;
132 | }
133 | .label {
134 | color: inherit;
135 | font-size: inherit;
136 | z-index: 2;
137 |
138 | &.pulse {
139 | animation: pulse 2s ease-in-out infinite;
140 | }
141 | }
142 | }
143 |
144 | @keyframes pulse {
145 | 0% {
146 | opacity: 1;
147 | }
148 | 50% {
149 | opacity: 0.4;
150 | }
151 | 100% {
152 | opacity: 1;
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/frontend/src/components/ExternalFile/index.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useRef, useEffect, useCallback} from "react"
2 | import classnames from "classnames"
3 | import useApi from "../../api/useApi"
4 |
5 | import Typography from "../Typography"
6 | import Collapse from "@mui/material/Collapse"
7 |
8 | import styles from "./index.module.scss"
9 |
10 | export default function ExternalFile(props) {
11 | const {onFinish, url, className, classes = {}} = props
12 |
13 | const {download} = useApi()
14 | const int = useRef(null)
15 |
16 | const [progress, setProgress] = useState(0)
17 | const [error, setError] = useState(false)
18 | const [finished, setFinished] = useState(false)
19 | const [collapse, setCollapse] = useState(false)
20 | const [fadeOut, setFadeOut] = useState(false)
21 |
22 | const upload = useCallback(async () => {
23 | setError(false)
24 | clearInterval(int.current)
25 | int.current = setInterval(() => {
26 | setProgress(value => (value += (0.3 * (0.9 - value)) / 1))
27 | }, 1000)
28 |
29 | try {
30 | const {key} = await download({url})
31 | if (!key) {
32 | setError(true)
33 | return
34 | }
35 | clearInterval(int.current)
36 | setProgress(1)
37 | setFinished(true)
38 | setTimeout(() => {
39 | setFadeOut(true)
40 | setTimeout(() => {
41 | setCollapse(true)
42 | setTimeout(() => {
43 | onFinish(key)
44 | }, 500)
45 | }, 500)
46 | }, 2000)
47 | } catch (err) {
48 | console.error(err)
49 | setError(true)
50 | }
51 | }, [url, download, onFinish])
52 |
53 | useEffect(() => {
54 | if (progress > 0) {
55 | return
56 | }
57 | upload()
58 | }, [upload, progress])
59 |
60 | return (
61 |
62 |
71 |
78 |
79 |
80 |
81 | {url.length > 50
82 | ? `${url.substring(0, 47).trim()}...`
83 | : url}
84 |
85 |
86 | External file
87 |
88 | {!error && (
89 |
90 | {progress < 1
91 | ? `Uploading: ${Math.round(progress * 100)}%`
92 | : finished
93 | ? "Done!"
94 | : "Processing..."}
95 |
96 | )}
97 | {error && (
98 |
103 | Error. Click here to try again.
104 |
105 | )}
106 |
107 |
108 |
109 | )
110 | }
111 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import ReactDOM from "react-dom/client"
3 | import {BrowserRouter} from "react-router-dom"
4 | import {ScrollToTop} from "react-router-scroll-to-top"
5 | import CssBaseline from "@mui/material/CssBaseline"
6 | import {ThemeProvider} from "@mui/material/styles"
7 | import {createTheme, StyledEngineProvider} from "@mui/material/styles"
8 | import SnackbarProvider from "./components/SnackbarProvider"
9 | import { unregister } from './registerServiceWorker';
10 | import App from "./App"
11 | import "./styles/globals.scss"
12 |
13 | unregister()
14 |
15 | const theme = createTheme({
16 | palette: {
17 | primary: {
18 | main: "#ef39a8",
19 | },
20 | background: {
21 | default: "#1c1b1b",
22 | },
23 | },
24 | shape: {
25 | borderRadius: 12,
26 | },
27 | typography: {
28 | fontSize: 14,
29 | fontFamily: `"Manrope", "Roboto", "Helvetica", "Arial", sans-serif`,
30 | h1: {
31 | fontSize: "96px",
32 | fontWeight: 300,
33 | lineHeight: "112px",
34 | },
35 | h2: {
36 | fontSize: "60px",
37 | fontWeight: 400,
38 | lineHeight: "72px",
39 | },
40 | h3: {
41 | fontSize: "50px",
42 | fontWeight: 600,
43 | lineHeight: "58px",
44 | },
45 | h4: {
46 | fontSize: "34px",
47 | fontWeight: 700,
48 | lineHeight: "36px",
49 | },
50 | h5: {
51 | fontSize: "24px",
52 | fontWeight: 700,
53 | lineHeight: "24px",
54 | },
55 | h6: {
56 | fontSize: "20px",
57 | fontWeight: 700,
58 | lineHeight: "24px",
59 | },
60 | subtitle1: {
61 | fontSize: "16px",
62 | fontWeight: 400,
63 | lineHeight: "24px",
64 | },
65 | subtitle1bold: {
66 | fontSize: "16px",
67 | fontWeight: 600,
68 | lineHeight: "24px",
69 | },
70 | subtitle2: {
71 | fontSize: "14px",
72 | fontWeight: 500,
73 | lineHeight: "24px",
74 | },
75 | subtitle2bold: {
76 | fontSize: "14px",
77 | fontWeight: 700,
78 | lineHeight: "24px",
79 | },
80 | body1: {
81 | fontSize: "16px",
82 | fontWeight: "400",
83 | lineHeight: "24px",
84 | },
85 | body2: {
86 | fontSize: "14px",
87 | fontWeight: "400",
88 | lineHeight: "20px",
89 | },
90 | button: {
91 | fontSize: "18px",
92 | fontWeight: "500",
93 | lineHeight: "20px",
94 | textTransform: "none",
95 | },
96 | caption: {
97 | fontSize: "12px",
98 | fontWeight: "400",
99 | lineHeight: "16px",
100 | },
101 | overline: {
102 | fontSize: "10px",
103 | fontWeight: "500",
104 | lineHeight: "16px",
105 | },
106 | },
107 | })
108 |
109 | ReactDOM.createRoot(document.getElementById("root")).render(
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | )
123 |
124 | // If you want to start measuring performance in your app, pass a function
125 | // to log results (for example: reportWebVitals(console.log))
126 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
127 | //reportWebVitals()
128 |
--------------------------------------------------------------------------------
/frontend/src/components/DroppedFile/index.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect, useCallback} from "react"
2 | import classnames from "classnames"
3 | import useApi from "../../api/useApi"
4 | import prettyBytes from "pretty-bytes"
5 |
6 | import Typography from "../Typography"
7 | import Collapse from "@mui/material/Collapse"
8 |
9 | import notFoundImage from "../../images/not-found.png"
10 |
11 | import styles from "./index.module.scss"
12 |
13 | export default function DroppedFile(props) {
14 | const {onFinish, file, className, classes = {}} = props
15 | const {name, size} = file
16 |
17 | const {uploadPhoto} = useApi()
18 |
19 | const [progress, setProgress] = useState(0)
20 | const [src, setSrc] = useState(notFoundImage)
21 | const [error, setError] = useState(false)
22 | const [finished, setFinished] = useState(false)
23 | const [collapse, setCollapse] = useState(false)
24 | const [fadeOut, setFadeOut] = useState(false)
25 |
26 | const upload = useCallback(async () => {
27 | setError(false)
28 | setProgress(0.01)
29 | const fileReader = new FileReader()
30 | fileReader.readAsDataURL(file)
31 | fileReader.addEventListener("load", fileReaderEvent => {
32 | setSrc(fileReaderEvent.target.result)
33 | })
34 | try {
35 | const {key} = await uploadPhoto(
36 | {photo: file},
37 | {
38 | onUploadProgress: ({progress}) => setProgress(progress),
39 | }
40 | )
41 | if (!key) {
42 | setError(true)
43 | return
44 | }
45 | setFinished(true)
46 | setTimeout(() => {
47 | setFadeOut(true)
48 | setTimeout(() => {
49 | setCollapse(true)
50 | setTimeout(() => {
51 | onFinish(key)
52 | }, 500)
53 | }, 500)
54 | }, 2000)
55 | } catch (err) {
56 | console.error(err)
57 | setError(true)
58 | }
59 | }, [file, uploadPhoto, onFinish])
60 |
61 | useEffect(() => {
62 | if (progress > 0) {
63 | return
64 | }
65 | upload()
66 | }, [upload, progress])
67 |
68 | return (
69 |
70 |
79 |
86 |
87 |
88 |
89 | {name.length > 50
90 | ? `${name.substring(0, 47).trim()}...`
91 | : name}
92 |
93 |
94 | Size: {prettyBytes(size).toUpperCase()}
95 |
96 | {!error && (
97 |
98 | {progress < 1
99 | ? `Uploading: ${Math.round(progress * 100)}%`
100 | : finished
101 | ? "Done!"
102 | : "Processing..."}
103 |
104 | )}
105 | {error && (
106 |
111 | Error. Click here to try again.
112 |
113 | )}
114 |
115 |
116 |
117 | )
118 | }
119 |
--------------------------------------------------------------------------------
/frontend/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
26 |
32 |
41 |
50 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
13 |
14 | The page will reload when you make changes.\
15 | You may also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!**
35 |
36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
39 |
40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
48 | ### Code Splitting
49 |
50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
51 |
52 | ### Analyzing the Bundle Size
53 |
54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
55 |
56 | ### Making a Progressive Web App
57 |
58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
59 |
60 | ### Advanced Configuration
61 |
62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
63 |
64 | ### Deployment
65 |
66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
67 |
68 | ### `npm run build` fails to minify
69 |
70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
71 |
--------------------------------------------------------------------------------
/frontend/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | )
20 |
21 | export default function register () {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
34 |
35 | if (!isLocalhost) {
36 | // Is not local host. Just register service worker
37 | registerValidSW(swUrl)
38 | } else {
39 | // This is running on localhost. Lets check if a service worker still exists or not.
40 | checkValidServiceWorker(swUrl)
41 | }
42 | })
43 | }
44 | }
45 |
46 | function registerValidSW (swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.')
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.')
65 | }
66 | }
67 | }
68 | }
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error)
72 | })
73 | }
74 |
75 | function checkValidServiceWorker (swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload()
88 | })
89 | })
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl)
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | )
99 | })
100 | }
101 |
102 | export function unregister () {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister()
106 | })
107 | }
108 | }
--------------------------------------------------------------------------------
/frontend/src/components/FileUploadField/index.js:
--------------------------------------------------------------------------------
1 | import React, {useCallback, useEffect, useState, useRef} from "react"
2 | import classnames from "classnames"
3 | import md5 from "md5"
4 | import {useSnackbar} from "notistack"
5 |
6 | import DroppedFile from "../DroppedFile"
7 | import ExternalFile from "../ExternalFile"
8 |
9 | import styles from "./index.module.scss"
10 |
11 | const getFileId = file =>
12 | md5(`${file.name}${file.size}${file.lastModified}${new Date().valueOf()}`)
13 |
14 | const FileUploadField = props => {
15 | const {
16 | onChange: onChangeProp,
17 | onFinish,
18 | value,
19 | accept,
20 | placeholder,
21 | name,
22 | className,
23 | classes = {},
24 | } = props
25 |
26 | const {enqueueSnackbar} = useSnackbar()
27 |
28 | const [showFullScreenDrop, setShowFullScreenDrop] = useState(false)
29 | const fullScreenDropRef = useRef(null)
30 |
31 | const onChange = useCallback(
32 | e => {
33 | const files = Array.from(e.target?.files || [])
34 | onChangeProp(
35 | files.map(file => {
36 | file.id = getFileId(file)
37 | return {
38 | type: "file",
39 | data: file,
40 | }
41 | })
42 | )
43 | },
44 | [onChangeProp]
45 | )
46 |
47 | useEffect(() => {
48 | const fullScreenDropEl = fullScreenDropRef.current
49 | let entered = false
50 |
51 | const processItems = items => {
52 | const result = []
53 | let isHtml = false
54 | for (let i = 0; i < items.length; i += 1) {
55 | const item = items[i]
56 | if (item.kind === "string" && item.type === "text/html") {
57 | isHtml = true
58 | item.getAsString(async htmlString => {
59 | try {
60 | const htmlDOM = new DOMParser().parseFromString(
61 | htmlString,
62 | "text/html"
63 | )
64 | const src =
65 | htmlDOM.getElementsByTagName("img")[0]?.src ||
66 | htmlDOM.getElementsByTagName("a")[0]?.href
67 | if (!src) return
68 | onChangeProp({
69 | type: "url",
70 | data: src,
71 | })
72 | } catch (err) {
73 | enqueueSnackbar({
74 | variant: "error",
75 | message: "Couldn't read the dropped file",
76 | })
77 | console.error(err)
78 | }
79 | })
80 | } else if (item.kind === "file" && !isHtml) {
81 | const file = item.getAsFile()
82 | if (/^image\/.+$/.test(file.type)) {
83 | file.id = getFileId(file)
84 | result.push({
85 | type: "file",
86 | data: file,
87 | })
88 | }
89 | }
90 | }
91 |
92 | if (result.length) onChangeProp(result)
93 | }
94 |
95 | const onDragEnter = e => {
96 | e.preventDefault()
97 | if (entered) return
98 | entered = true
99 | setShowFullScreenDrop(true)
100 | }
101 |
102 | const onDragLeave = e => {
103 | e.preventDefault()
104 | entered = false
105 | setShowFullScreenDrop(false)
106 | }
107 |
108 | const onDrop = e => {
109 | onDragLeave(e)
110 | processItems(e.dataTransfer.items)
111 | }
112 |
113 | const onPaste = e => {
114 | const items = (e.clipboardData || e.originalEvent.clipboardData)
115 | .items
116 | processItems(items)
117 | }
118 |
119 | document.addEventListener("paste", onPaste)
120 | window.addEventListener("dragover", onDragEnter)
121 | //window.addEventListener("drop", onDrop)
122 | //window.addEventListener("dragend", onDragLeave)
123 | fullScreenDropEl.addEventListener("dragleave", onDragLeave)
124 | fullScreenDropEl.addEventListener("dragend", onDragLeave)
125 | fullScreenDropEl.addEventListener("drop", onDrop)
126 |
127 | return () => {
128 | document.removeEventListener("paste", onPaste)
129 | window.removeEventListener("dragover", onDragEnter)
130 | //window.removeEventListener("drop", onDrop)
131 | //window.removeEventListener("dragend", onDragLeave)
132 | fullScreenDropEl.removeEventListener("dragleave", onDragLeave)
133 | fullScreenDropEl.removeEventListener("dragend", onDragLeave)
134 | fullScreenDropEl.removeEventListener("drop", onDrop)
135 | }
136 | }, [onChangeProp, enqueueSnackbar])
137 |
138 | return (
139 |
140 |
147 |
{placeholder}
148 |
149 |
150 |
151 | {value &&
152 | value.map(file =>
153 | file.type === "file" ? (
154 | onFinish(key, file)}
159 | />
160 | ) : (
161 | onFinish(key, file)}
166 | />
167 | )
168 | )}
169 |
170 |
171 |
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 |
201 | Download
202 |
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 |
201 | Save
202 | ,
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 |
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 |
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 |
680 | Create
681 |
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 |
859 | Create
860 | ,
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 |
918 | Group to White Hole
919 |
920 | )}
921 | {!whiteHoleId && whiteHoles.length > 0 && (
922 |
927 | Add to White Hole
928 |
929 | )}
930 | setCheckedImages([])}
934 | >
935 | Uncheck
936 |
937 |
938 |
947 |
953 | Delete
954 |
955 |
956 |
957 |
962 | Selected:{" "}
963 |
968 | {numberWithSpaces(checkedImages.length)}
969 |
970 |
971 |
972 |
973 |
974 |
975 |
976 | {whiteHoleVisibility === "public" ? (
977 |
985 |
990 | Deta Black Hole
991 |
992 | ) : (
993 |
994 |
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 |
1046 | Copy public url
1047 |
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 |
1071 | Delete White
1072 | Hole
1073 |
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 |
1217 | Upload
1218 |
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 |
1268 | Integrations
1269 |
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 |
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 |
--------------------------------------------------------------------------------