diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 00ad71f..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "typescript.tsdk": "node_modules\\typescript\\lib" -} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b33c0e0..efb5fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1451,6 +1451,11 @@ } } }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, "clean-css": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz", @@ -5370,6 +5375,14 @@ "prop-types": "^15.6.2" } }, + "react-avatar-editor": { + "version": "12.0.0-beta.0", + "resolved": "https://registry.npmjs.org/react-avatar-editor/-/react-avatar-editor-12.0.0-beta.0.tgz", + "integrity": "sha512-7vrkqjmXDCZuBRpRsrldeN0/BAW1/rx/k+5WE1AhvQMMGXYGwy1GY3YF97okzgBYwZV3OocGyx8oauOcTz2xAw==", + "requires": { + "react-draggable": "^4.1.0" + } + }, "react-dom": { "version": "16.12.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", @@ -5381,6 +5394,15 @@ "scheduler": "^0.18.0" } }, + "react-draggable": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.2.0.tgz", + "integrity": "sha512-5wFq//gEoeTYprnd4ze8GrFc+Rbnx+9RkOMR3vk4EbWxj02U6L6T3yrlKeiw4X5CtjD2ma2+b3WujghcXNRzkw==", + "requires": { + "classnames": "^2.2.5", + "prop-types": "^15.6.0" + } + }, "react-is": { "version": "16.9.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", diff --git a/package.json b/package.json index 167b42f..54c7d21 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "lodash": "^4.17.15", "moment": "^2.24.0", "react": "^16.12.0", + "react-avatar-editor": "^12.0.0-beta.0", "react-dom": "^16.12.0", "react-redux": "^7.1.3", "react-router-dom": "^5.1.2", diff --git a/src/app/components/avatar-editor.tsx b/src/app/components/avatar-editor.tsx new file mode 100644 index 0000000..43ff789 --- /dev/null +++ b/src/app/components/avatar-editor.tsx @@ -0,0 +1,31 @@ +import React, { FC, useRef, useState } from 'react' +import Editor from 'react-avatar-editor' +import Slider from '../components/slider' + +interface Props { + file: File + width: number + height: number +} + +const AvatarEditor: FC = ({ file, width, height }) => { + const ref = useRef(null) + const [border, setBorder] = useState(50) + const [scale, setScale] = useState(1.2) + + return ( +
+ + + +
+ ) +} + +export default AvatarEditor diff --git a/src/app/components/controls/button.tsx b/src/app/components/controls/button.tsx index 253cd18..4591036 100644 --- a/src/app/components/controls/button.tsx +++ b/src/app/components/controls/button.tsx @@ -19,7 +19,7 @@ const Button: FC = ({ text, icon, loading, color, backgroundColor, onClic const content = () => ( <> {icon && - + } diff --git a/src/app/components/controls/cover-image-field.tsx b/src/app/components/controls/cover-image-field.tsx index 068144c..211f309 100644 --- a/src/app/components/controls/cover-image-field.tsx +++ b/src/app/components/controls/cover-image-field.tsx @@ -9,7 +9,16 @@ interface Props { } const CoverImageField: FC = ({ name, label = 'Cover Image', help = 'Approx 400 x 200. Max 5 MBs.' }) => { - return + return ( + + ) } export default CoverImageField diff --git a/src/app/components/controls/file-field.tsx b/src/app/components/controls/file-field.tsx index 442bbbf..ae8d476 100644 --- a/src/app/components/controls/file-field.tsx +++ b/src/app/components/controls/file-field.tsx @@ -1,4 +1,4 @@ -import React, { FC, ChangeEvent, useState } from 'react' +import React, { FC, ChangeEvent, useState, useRef } from 'react' import { useSelector, useDispatch } from 'react-redux' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUpload } from '@fortawesome/free-solid-svg-icons' @@ -14,10 +14,13 @@ import { AppState, AppThunkDispatch, SasResponse, NotificationType } from '../.. import Progress from '../../components/progress' import FieldLabel from '../../components/controls/field-label' +import AvatarEditor from '../../components/avatar-editor' interface Props { name: string label: string + width: number + height: number help?: string previewWidth?: number maxSize?: number @@ -28,56 +31,69 @@ const FileField: FC = props => { const value = useSelector(state => getFieldValue(state, props.name, '')) const dispatch = useDispatch() - const { name, label, help, previewWidth = 128, maxSize = MEDIA_DEFAULT_MAX_SIZE } = props - const [progress, setProgress] = useState(0) + const [file, setFile] = useState() const [uploading, setUploading] = useState(false) const [uploaded, setUploaded] = useState(false) - const handleChange = async (event: ChangeEvent) => { - if (event.target.files) { - const file = event.target.files[0] + const { + name, + label, + width, + height, + help, + previewWidth = 128, + maxSize = MEDIA_DEFAULT_MAX_SIZE, + } = props - if (file.size > maxSize) { + const handleChange = async (event: ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + if (event.target.files[0].size > maxSize) { const maxSizeString = Math.round(maxSize / 1024 / 1024) dispatch(showNotification(NotificationType.Error, `Files must be less than ${maxSizeString} MBs`)) return } - const ext = file.name.substring(file.name.lastIndexOf('.')) - const { sas, blobUrl, id } = await apiFetch({ path: '/v1/sas' }) - const filename = `${id}${ext}` - - setUploading(true) - - try { - const blockBlobClient = new BlockBlobClient(`${blobUrl}/${filename}?${sas}`, new AnonymousCredential()) - await blockBlobClient.uploadBrowserData(file, { - onProgress: p => { - setProgress((p.loadedBytes / file.size) * 100) - } - }) - - await apiFetch({ - path: '/v1/media', - method: 'post', - body: { - name: filename, - size: file.size, - type: file.type, - originalName: file.name, - } - }) - - dispatch(setFieldValue(name, `${blobUrl}/${filename}`)) - setUploaded(true) - } catch (err) { - console.error(err) - dispatch(showNotification(NotificationType.Error, `Upload error: ${err}`)) - } + setFile(event.target.files[0]) + } + } + + const handleUpload = async () => { + if (!file) return + setUploading(true) + + const ext = file.name.substring(file.name.lastIndexOf('.')) + const { sas, blobUrl, id } = await apiFetch({ path: '/v1/sas' }) + const filename = `${id}${ext}` + const url = `${blobUrl}/${filename}` + + try { + const blockBlobClient = new BlockBlobClient(`${url}?${sas}`, new AnonymousCredential()) + await blockBlobClient.uploadBrowserData(file, { + onProgress: p => { + setProgress((p.loadedBytes / file.size) * 100) + } + }) + + await apiFetch({ + path: '/v1/media', + method: 'post', + body: { + name: filename, + size: file.size, + type: file.type, + originalName: file.name, + } + }) - setUploading(false) + dispatch(setFieldValue(name, url)) + setUploaded(true) + } catch (err) { + console.error(err) + dispatch(showNotification(NotificationType.Error, `Upload error: ${err}`)) } + + setUploading(false) } const handleDelete = async () => { @@ -91,6 +107,18 @@ const FileField: FC = props => { dispatch(setFieldValue(name, '')) } + if (file) { + return ( +
+ {label} (Unsaved) + +
+ ) + } + if (uploading) { return (
@@ -107,9 +135,7 @@ const FileField: FC = props => {
- {value.split('/').pop()} -    - ( handleDelete()}>Delete) + handleDelete()}>Delete
} @@ -117,9 +143,9 @@ const FileField: FC = props => {

{help}

diff --git a/src/app/components/controls/icon-image-field.tsx b/src/app/components/controls/icon-image-field.tsx index f0acd23..1007095 100644 --- a/src/app/components/controls/icon-image-field.tsx +++ b/src/app/components/controls/icon-image-field.tsx @@ -9,7 +9,16 @@ interface Props { } const IconImageField: FC = ({ name, label = 'Icon Image', help = 'Approx 32 x 32. Max 1 MB.' }) => { - return + return ( + + ) } export default IconImageField diff --git a/src/app/components/controls/image-field.tsx b/src/app/components/controls/image-field.tsx index c7a7b54..5aae08d 100644 --- a/src/app/components/controls/image-field.tsx +++ b/src/app/components/controls/image-field.tsx @@ -9,7 +9,16 @@ interface Props { } const ImageField: FC = ({ name, label = 'Image', help = 'Approx 128 x 128. Max 5 MBs.' }) => { - return + return ( + + ) } export default ImageField diff --git a/src/app/components/controls/password-field.tsx b/src/app/components/controls/password-field.tsx index 2a395c2..ec37ba4 100644 --- a/src/app/components/controls/password-field.tsx +++ b/src/app/components/controls/password-field.tsx @@ -79,7 +79,7 @@ const PasswordField: FC = ({
Password
-
+
diff --git a/src/app/components/controls/select-field.tsx b/src/app/components/controls/select-field.tsx index edab7b5..2718313 100644 --- a/src/app/components/controls/select-field.tsx +++ b/src/app/components/controls/select-field.tsx @@ -39,7 +39,7 @@ const SelectField: FC = ({ {label}
{icon && -
+
} diff --git a/src/app/components/controls/static-field.tsx b/src/app/components/controls/static-field.tsx index 6f7b783..837b825 100644 --- a/src/app/components/controls/static-field.tsx +++ b/src/app/components/controls/static-field.tsx @@ -18,7 +18,7 @@ const StaticField: FC = ({ label, value, icon }) => { {label}
{icon && -
+
} diff --git a/src/app/components/controls/text-field.tsx b/src/app/components/controls/text-field.tsx index 36d589c..e53ad5b 100644 --- a/src/app/components/controls/text-field.tsx +++ b/src/app/components/controls/text-field.tsx @@ -54,7 +54,7 @@ const TextField: FC = ({ {label}
{icon && -
+
} diff --git a/src/app/components/help-text.tsx b/src/app/components/help-text.tsx index e1ef43e..c3164e4 100644 --- a/src/app/components/help-text.tsx +++ b/src/app/components/help-text.tsx @@ -1,11 +1,9 @@ import React, { FC } from 'react' import { useTheme } from '../hooks' -const Notification: FC = ({ children }) => { +const HelpText: FC = ({ children }) => { const theme = useTheme() - return ( -

{children}

- ) + return

{children}

} -export default Notification +export default HelpText diff --git a/src/app/components/logo.tsx b/src/app/components/logo.tsx index 457eb17..e2c870b 100644 --- a/src/app/components/logo.tsx +++ b/src/app/components/logo.tsx @@ -7,7 +7,7 @@ const Logo: FC = () => { const history = useHistory() return ( -
history.push('/')}> +
history.push('/')}> F
) diff --git a/src/app/components/pages/admin-apps.tsx b/src/app/components/pages/admin-apps.tsx index e58b26d..759a9d9 100644 --- a/src/app/components/pages/admin-apps.tsx +++ b/src/app/components/pages/admin-apps.tsx @@ -45,7 +45,7 @@ const AdminApps: FC = () => { } useEffect(() => { - setTitle('Admin \\ Apps') + setTitle('Admin / Apps') const init = async () => { try { diff --git a/src/app/components/pages/admin-groups.tsx b/src/app/components/pages/admin-groups.tsx index cded583..54658f6 100644 --- a/src/app/components/pages/admin-groups.tsx +++ b/src/app/components/pages/admin-groups.tsx @@ -35,7 +35,7 @@ const AdminGroups: FC = () => { } useEffect(() => { - setTitle('Admin \\ Groups') + setTitle('Admin / Groups') const init = async () => { try { diff --git a/src/app/components/pages/view-user.tsx b/src/app/components/pages/view-user.tsx index 3a8dd6d..45dc9d2 100644 --- a/src/app/components/pages/view-user.tsx +++ b/src/app/components/pages/view-user.tsx @@ -120,7 +120,7 @@ const ViewUser: FC = () => {
{subscribed &&
) } diff --git a/src/app/styles/app.css b/src/app/styles/app.css index 89d91ef..a43539b 100644 --- a/src/app/styles/app.css +++ b/src/app/styles/app.css @@ -2,11 +2,21 @@ @import "../../../node_modules/normalize.css/normalize.css"; :root { - --default-border: 1px solid; --default-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; --input-padding: 0.5rem 0.75rem; --content-width: 600px; --menu-width: 270px; + --transition-duration: 1s; + + --color-primary: #000; + --color-alternate: #ddd; + --color-secondary: #333; + --color-background-primary: #fff; + --color-background-secondary: #eee; + --color-text: #555; + --color-red: #ff1a1a; + --color-green: #00802b; + --color-blue: #005ce6; } html { @@ -15,49 +25,28 @@ html { font-weight: 300; } -body, -div, -h1, -h2, -input, -textarea, -select, -label, -button, -section, -p.help, -div.icon { - transition: color 1s; -} - -div, -input, -textarea, -select, -button, -section, -div.content, -div.menu, -div.icon { - transition: background-color 1s, border-color 1s; -} - body { + background-color: var(--color-background-primary); font-family: var(--default-font); line-height: 1.2; margin: 0px; padding: 0px; + transition: background-color var(--transition-duration); } input, textarea, select { - border: var(--default-border); + background-color: var(--color-background-secondary); + border: solid 1px var(--color-primary); border-radius: 0; -webkit-box-sizing: border-box; box-sizing: border-box; + color: var(--color-text); font-family: var(--default-font); font-size: 0.9rem; margin: 0px; + outline: none; padding: var(--input-padding); + transition: background-color var(--transition-duration), border var(--transition-duration); width: 100%; } @@ -77,13 +66,47 @@ input[type="checkbox"] { width: initial; } +input[type="range"] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: var(--color-background-secondary); + border-radius: 5px; + display: block; + height: 10px; + outline: none; + width: 8rem; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: var(--color-secondary); + border-radius: 50%; + cursor: pointer; + height: 20px; + width: 20px; +} + +input[type="range"]::-moz-range-thumb { + border-radius: 50%; + cursor: pointer; + height: 20px; + width: 20px; +} + h1 { + color: var(--color-primary); font-size: 2rem; margin: 0.5rem 0; + transition: color var(--transition-duration); } h2 { + color: var(--color-primary); font-size: 1.2rem; + transition: color var(--transition-duration); } h1 + h2 { @@ -94,12 +117,31 @@ a { text-decoration: none; } +a:link { + color: var(--color-primary); + transition: color var(--transition-duration); +} + +a:hover { + color: var(--color-secondary); + transition: color var(--transition-duration); +} + +a:visited { + color: var(--color-secondary); + transition: color var(--transition-duration); +} + hr { - border: 1px solid; + border: 1px solid var(--color-primary); + color: var(--color-primary); margin: 1rem 0px; + transition: border var(--transition-duration), color var(--transition-duration); } main { + background-color: var(--color-background-primary); + color: var(--color-text); bottom: 0; display: flex; justify-content: center; @@ -108,10 +150,14 @@ main { position: absolute; right: 0; top: 0; + transition: background-color var(--transition-duration), color var(--transition-duration); } section { + background-color: var(--color-background-primary); + color: var(--color-text); padding: 1rem; + transition: background-color var(--transition-duration), color var(--transition-duration); } iframe { @@ -129,6 +175,17 @@ button, label.file-input { font-weight: 700; padding: 0.5rem 1rem; min-width: 100px; + transition: background-color var(--transition-duration), color var(--transition-duration); +} + +button.primary { + background-color: var(--color-primary); + color: var(--color-alternate); +} + +button.secondary { + background-color: var(--color-secondary); + color: var(--color-secondary); } div.buttons { @@ -145,7 +202,10 @@ div.logo { --size: 40px; --padding-top: 8px; + background-color: var(--color-primary); border-radius: 90px; + color: var(--color-alternate); + cursor: pointer; font-size: 20px; font-weight: bold; height: calc(var(--size) - var(--padding-top)); @@ -153,17 +213,21 @@ div.logo { padding-top: var(--padding-top); position: fixed; text-align: center; + transition: background-color var(--transition-duration), color var(--transition-duration); width: var(--size); } div.content-container { + background-color: var(--color-background-primary); width: var(--content-width); + transition: background-color var(--transition-duration); } div.content { - border-left: var(--default-border); - border-right: var(--default-border); + border-left: solid 1px var(--color-background-secondary); + border-right: solid 1px var(--color-background-primary); padding-bottom: 3rem; + transition: border-left var(--transition-duration), border-right var(--transition-duration); } div.menu-container { @@ -173,12 +237,15 @@ div.menu-container { } div.menu { + background-color: var(--color-background-secondary); + border-color: var(--color-background-primary); bottom: 0; display: flex; flex-direction: column; margin: 0px; position: fixed; top: 0; + transition: background-color var(--transition-duration), border-color var(--transition-duration); width: var(--menu-width); } @@ -192,8 +259,10 @@ div.menu > nav > div { } div.spinner { + color: var(--color-primary); padding: 1rem; text-align: center; + transition: color var(--transition-duration); } .icon { @@ -202,9 +271,11 @@ div.spinner { } footer { + color: var(--color-text); font-size: 0.8rem; padding: 0.9rem; text-align: center; + transition: color var(--transition-duration); } table { @@ -212,8 +283,16 @@ table { width: 100%; } +table tr { + transition: background-color var(--transition-duration); +} + table tr:nth-child(even) { - background-color: transparent !important; + background-color: transparent; +} + +table tr:nth-child(odd) { + background-color: var(--color-background-secondary); } table td { @@ -228,14 +307,18 @@ span.tag { } div.tabs { + background-color: var(--color-background-secondary); border-radius: 20px; display: flex; font-size: 1rem; justify-content: space-around; + transition: background-color var(--transition-duration); } div.tabs > div { + border-color: var(--color-primary); padding: 0.5rem; + transition: border-color var(--transition-duration); } div.tabs > div.active { @@ -243,16 +326,20 @@ div.tabs > div.active { } div.progress { - border: 1px solid; + background-color: var(--color-background-secondary); + border: 1px solid var(--color-primary); height: 1rem; margin: 1rem; max-height: 100px; min-height: 10px; padding: 0px; + transition: background-color var(--transition-duration), border var(--transition-duration); } div.progress > div { + background-color: var(--color-secondary); height: 100%; + transition: background-color var(--transition-duration); } div.field { @@ -260,6 +347,7 @@ div.field { } div.field label { + color: var(--color-secondary); display: block; font-weight: 700; margin-bottom: 0.5rem; @@ -274,12 +362,15 @@ div.control-container { padding: 0.5rem 0px; } -div.control-container > div.icon { +div.control-icon { + background-color: var(--color-primary); + color: var(--color-alternate); margin: 0px; padding: var(--input-padding); + transition: background-color var(--transition-duration), color var(--transition-duration); } -div.icon > svg { +div.control-icon > svg { vertical-align: middle; } @@ -288,8 +379,10 @@ div.control { } p.help { + color: var(--color-primary); font-size: 0.8rem; margin-top: -0.25rem; + transition: color var(--transition-duration); } div.search { @@ -333,12 +426,16 @@ nav.level > div { } nav.level p.label { + color: var(--color-secondary); font-size: 0.9rem; font-weight: bold; + transition: color var(--transition-duration); } nav.level p.content { + color: var(--color-text); font-size: 1.1rem; + transition: color var(--transition-duration); } p.label + p.content { @@ -352,25 +449,40 @@ div.member { } div.composer-container { - border-top: var(--default-border); - border-bottom: var(--default-border); + border-top: solid 1px var(--color-background-secondary); + border-bottom: solid 1px var(--color-background-secondary); + transition: border-top var(--transition-duration), border-bottom var(--transition-duration); } div.composer-empty, div.composer-error { font-size: 0.8rem; text-align: center; padding: 1.5rem; + transition: background-color var(--transition-duration), color var(--transition-duration); +} + +div.composer-empty { + background-color: var(--color-background-secondary); + color: var(--color-secondary); +} + +div.composer-error { + background-color: var(--color-background-secondary); + color: var(--color-red); } div.installations { + background-color: var(--color-background-secondary); display: flex; padding: 0.5rem; + transition: background-color var(--transition-duration); } div.installations > div { - border-right: var(--default-border); + border-right: solid 1px var(--color-background-primary); padding: 0.5rem; text-align: center; + transition: border-right var(--transition-duration); } div.installations > div > p { diff --git a/src/app/utils/index.ts b/src/app/utils/index.ts index cf69ba4..57dac10 100644 --- a/src/app/utils/index.ts +++ b/src/app/utils/index.ts @@ -5,7 +5,7 @@ export const objectToQuerystring = (obj: object) => Object.entries(obj).filter(( export function setTitle(title: string, decorate: boolean = true) { if (decorate) { - document.title = `${title} \\ Flexor` + document.title = `${title} / Flexor` } else { document.title = title } diff --git a/src/server/server.ts b/src/server/server.ts index 3523701..0c521e3 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -29,11 +29,9 @@ server.get('/*', {}, (_, reply) => { const port = parseInt(process.env.PORT!, 10) -server.listen(port, (err, address) => { +server.listen(port, err => { if (err) { server.log.error(err) process.exit(1) } - - server.log.info(`✊🏾 Flexor Web listening at ${address}`) }) \ No newline at end of file