wip: More account logic
This commit is contained in:
parent
132efebe5b
commit
f548e1ed8f
39 changed files with 523 additions and 2952 deletions
|
@ -76,6 +76,7 @@ packageJson:
|
||||||
# Components
|
# Components
|
||||||
"./components/Account": "./components/Account/index.mjs"
|
"./components/Account": "./components/Account/index.mjs"
|
||||||
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs"
|
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs"
|
||||||
|
"./components/Button": "./components/Button/index.mjs"
|
||||||
"./components/Control": "./components/Control/index.mjs"
|
"./components/Control": "./components/Control/index.mjs"
|
||||||
"./components/CopyToClipboard": "./components/CopyToClipboard/index.mjs"
|
"./components/CopyToClipboard": "./components/CopyToClipboard/index.mjs"
|
||||||
"./components/Docusaurus": "./components/Docusaurus/index.mjs"
|
"./components/Docusaurus": "./components/Docusaurus/index.mjs"
|
||||||
|
|
53
packages/react/components/Account/Export.mjs
Normal file
53
packages/react/components/Account/Export.mjs
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
// Context
|
||||||
|
import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus'
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import React, { useState, useContext } from 'react'
|
||||||
|
import { useAccount } from '@freesewing/react/hooks/useAccount'
|
||||||
|
import { useBackend } from '@freesewing/react/hooks/useBackend'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import { Link as WebLink } from '@freesewing/react/components/Link'
|
||||||
|
import { DownloadIcon } from '@freesewing/react/components/Icon'
|
||||||
|
import { Popout } from '@freesewing/react/components/Popout'
|
||||||
|
import { IconButton } from '@freesewing/react/components/Button'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Component for the account/actions/export page
|
||||||
|
*/
|
||||||
|
export const Export = () => {
|
||||||
|
// Hooks
|
||||||
|
const backend = useBackend()
|
||||||
|
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [link, setLink] = useState()
|
||||||
|
|
||||||
|
// Helper method to export account
|
||||||
|
const exportData = async () => {
|
||||||
|
setLoadingStatus([true, 'Contacting backend'])
|
||||||
|
const [status, body] = await backend.exportAccount()
|
||||||
|
if (status === 200) {
|
||||||
|
setLink(body.data)
|
||||||
|
setLoadingStatus([true, 'All done', true, true])
|
||||||
|
} else setLoadingStatus([true, 'Something went wrong, please report this', true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
{link ? (
|
||||||
|
<Popout link>
|
||||||
|
<h5>Your data was exported and is available for download at the following location:</h5>
|
||||||
|
<p className="text-lg">
|
||||||
|
<WebLink href={link}>{link}</WebLink>
|
||||||
|
</p>
|
||||||
|
</Popout>
|
||||||
|
) : null}
|
||||||
|
<p>Click below to export your personal FreeSewing data</p>
|
||||||
|
<IconButton onClick={exportData} title="Export your data">
|
||||||
|
<DownloadIcon />
|
||||||
|
Export Your Data
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
181
packages/react/components/Account/Import.mjs
Normal file
181
packages/react/components/Account/Import.mjs
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
// Context
|
||||||
|
import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus'
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import React, { useState, useContext } from 'react'
|
||||||
|
import { useAccount } from '@freesewing/react/hooks/useAccount'
|
||||||
|
import { useBackend } from '@freesewing/react/hooks/useBackend'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import { Link as WebLink } from '@freesewing/react/components/Link'
|
||||||
|
import { SaveIcon } from '@freesewing/react/components/Icon'
|
||||||
|
import { FileInput } from '@freesewing/react/components/Input'
|
||||||
|
import { Popout } from '@freesewing/react/components/Popout'
|
||||||
|
import { Yaml } from '@freesewing/react/components/Yaml'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Component for the account/bio page
|
||||||
|
*
|
||||||
|
* @params {object} props - All React props
|
||||||
|
* @params {bool} props.welcome - Set to true to use this component on the welcome page
|
||||||
|
* @params {function} props.Link - A framework specific Link component for client-side routing
|
||||||
|
*/
|
||||||
|
export const ImportSet = () => {
|
||||||
|
// Hooks
|
||||||
|
const backend = useBackend()
|
||||||
|
const { account } = useAccount()
|
||||||
|
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
||||||
|
|
||||||
|
// Helper method to upload/save a set
|
||||||
|
const uploadSet = async (upload) => {
|
||||||
|
setLoadingStatus([true, 'Uploading data'])
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
const chunks = upload.split(',')
|
||||||
|
if (chunks[0].includes('json')) data = JSON.parse(atob(chunks[1]))
|
||||||
|
else data = yaml.parse(atob(chunks[1]))
|
||||||
|
if (!Array.isArray(data)) data = [data]
|
||||||
|
/*
|
||||||
|
* Treat each set
|
||||||
|
*/
|
||||||
|
for (const set of data) {
|
||||||
|
if (set.measurements || set.measies) {
|
||||||
|
const name = set.name || 'J. Doe'
|
||||||
|
setLoadingStatus([true, `Importing ${name}`])
|
||||||
|
const [status, body] = await backend.createSet({
|
||||||
|
name: set.name || 'J. Doe',
|
||||||
|
units: set.units || 'metric',
|
||||||
|
notes: set.notes || '',
|
||||||
|
measies: set.measurements || set.measies,
|
||||||
|
userId: account.id,
|
||||||
|
})
|
||||||
|
if (status === 200) setLoadingStatus([true, `Imported ${name}`, true, true])
|
||||||
|
else setLoadingStatus([true, `Import of ${name} failed`, true, false])
|
||||||
|
} else {
|
||||||
|
setLoadingStatus([true, `Invalid format`, true, false])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
setLoadingStatus([true, `Import of ${name || 'file'} failed`, true, false])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<FileInput
|
||||||
|
label="Measurements file (YAML / JSON)"
|
||||||
|
update={uploadSet}
|
||||||
|
current=""
|
||||||
|
id="file"
|
||||||
|
dropzoneConfig={{
|
||||||
|
accept: {
|
||||||
|
'application/json': ['.json'],
|
||||||
|
'application/yaml': ['.yaml', '.yml'],
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
multiple: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Popout tip>
|
||||||
|
<p>
|
||||||
|
To import a measurement set, you should have a JSON or YAML file that has the following
|
||||||
|
structure:
|
||||||
|
</p>
|
||||||
|
<Yaml
|
||||||
|
js={{
|
||||||
|
name: 'Joost',
|
||||||
|
units: 'metric',
|
||||||
|
notes: 'These are my notes',
|
||||||
|
measurements: { biceps: 335, wrist: 190 },
|
||||||
|
}}
|
||||||
|
title="measurements.yaml"
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
Your file can either contain a single measurements set, or an array/list of multiple
|
||||||
|
measurements sets.
|
||||||
|
</p>
|
||||||
|
</Popout>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Hooks
|
||||||
|
const { account, setAccount } = useAccount()
|
||||||
|
const backend = useBackend()
|
||||||
|
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [bio, setBio] = useState(account.bio)
|
||||||
|
|
||||||
|
// Helper method to save bio
|
||||||
|
const save = async () => {
|
||||||
|
setLoadingStatus([true, 'Saving bio'])
|
||||||
|
const [status, body] = await backend.updateAccount({ bio })
|
||||||
|
if (status === 200 && body.result === 'success') {
|
||||||
|
setAccount(body.account)
|
||||||
|
setLoadingStatus([true, 'Bio updated', true, true])
|
||||||
|
} else setLoadingStatus([true, 'Something went wrong. Please report this', true, true])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next step in the onboarding
|
||||||
|
const nextHref =
|
||||||
|
welcomeSteps[account.control].length > 5
|
||||||
|
? '/welcome/' + welcomeSteps[account.control][6]
|
||||||
|
: '/docs/about/guide'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<h6>Tell people a little bit about yourself.</h6>
|
||||||
|
<MarkdownInput id="account-bio" label="Bio" update={setBio} current={bio} placeholder="Bio" />
|
||||||
|
<p className="text-right">
|
||||||
|
<button className="daisy-btn daisy-btn-primary w-full lg:w-auto mt-8" onClick={save}>
|
||||||
|
<SaveIcon /> Save Bio
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{welcome ? (
|
||||||
|
<>
|
||||||
|
<ContinueButton btnProps={{ href: nextHref }} link />
|
||||||
|
{welcomeSteps[account.control].length > 0 ? (
|
||||||
|
<>
|
||||||
|
<progress
|
||||||
|
className="progress progress-primary w-full mt-12"
|
||||||
|
value={600 / welcomeSteps[account.control].length}
|
||||||
|
max="100"
|
||||||
|
></progress>
|
||||||
|
<span className="pt-4 text-sm font-bold opacity-50">
|
||||||
|
6 / {welcomeSteps[account.control].length}
|
||||||
|
</span>
|
||||||
|
<Icons
|
||||||
|
done={welcomeSteps[account.control].slice(0, 5)}
|
||||||
|
todo={welcomeSteps[account.control].slice(6)}
|
||||||
|
current="bio"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Dependencies
|
||||||
|
import { useContext } from 'react'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
// Context
|
||||||
|
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
||||||
|
// Hooks
|
||||||
|
import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||||
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
|
// Components
|
||||||
|
import { FileInput } from 'shared/components/inputs.mjs'
|
||||||
|
import { Popout } from 'shared/components/popout/index.mjs'
|
||||||
|
import { linkClasses } from 'shared/components/link.mjs'
|
||||||
|
import yaml from 'yaml'
|
||||||
|
|
||||||
|
export const ns = ['account', 'status']
|
||||||
|
|
||||||
|
export const Importer = () => {
|
||||||
|
// Hooks
|
||||||
|
*/
|
50
packages/react/components/Account/Reload.mjs
Normal file
50
packages/react/components/Account/Reload.mjs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// Context
|
||||||
|
import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus'
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import React, { useState, useContext } from 'react'
|
||||||
|
import { useAccount } from '@freesewing/react/hooks/useAccount'
|
||||||
|
import { useBackend } from '@freesewing/react/hooks/useBackend'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import { Link as WebLink } from '@freesewing/react/components/Link'
|
||||||
|
import { ReloadIcon } from '@freesewing/react/components/Icon'
|
||||||
|
import { Popout } from '@freesewing/react/components/Popout'
|
||||||
|
import { IconButton } from '@freesewing/react/components/Button'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Component for the account/actions/export page
|
||||||
|
*/
|
||||||
|
export const Reload = () => {
|
||||||
|
// Hooks
|
||||||
|
const backend = useBackend()
|
||||||
|
const { setAccount } = useAccount()
|
||||||
|
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
||||||
|
|
||||||
|
// Helper method to reload account
|
||||||
|
const reload = async () => {
|
||||||
|
setLoadingStatus([true, 'Contacting backend'])
|
||||||
|
const [status, body] = await backend.reloadAccount()
|
||||||
|
if (status === 200) {
|
||||||
|
setAccount(body.account)
|
||||||
|
setLoadingStatus([true, 'All done', true, true])
|
||||||
|
} else setLoadingStatus([true, 'This did not go as planned. Please report this.', true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<p>
|
||||||
|
The data stored in your browser can sometimes get out of sync with the data stored in our
|
||||||
|
backend.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This lets you reload your account data from the backend. It has the same effect as signing
|
||||||
|
out, and then signing in again.
|
||||||
|
</p>
|
||||||
|
<IconButton onClick={reload}>
|
||||||
|
<ReloadIcon />
|
||||||
|
Reload Account Data
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
81
packages/react/components/Account/Remove.mjs
Normal file
81
packages/react/components/Account/Remove.mjs
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
// Context
|
||||||
|
import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus'
|
||||||
|
import { ModalContext } from '@freesewing/react/context/Modal'
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import React, { useState, useContext } from 'react'
|
||||||
|
import { useAccount } from '@freesewing/react/hooks/useAccount'
|
||||||
|
import { useBackend } from '@freesewing/react/hooks/useBackend'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import { Link as WebLink } from '@freesewing/react/components/Link'
|
||||||
|
import { BackIcon as ExitIcon, TrashIcon } from '@freesewing/react/components/Icon'
|
||||||
|
import { Popout } from '@freesewing/react/components/Popout'
|
||||||
|
import { IconButton } from '@freesewing/react/components/Button'
|
||||||
|
import { ModalWrapper } from '@freesewing/react/components/Modal'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Component for the account/actions/remove page
|
||||||
|
*/
|
||||||
|
export const Remove = () => {
|
||||||
|
// Hooks
|
||||||
|
const backend = useBackend()
|
||||||
|
const { signOut, account } = useAccount()
|
||||||
|
|
||||||
|
// Context
|
||||||
|
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
||||||
|
const { setModal, clearModal } = useContext(ModalContext)
|
||||||
|
|
||||||
|
// Helper method to remove the account
|
||||||
|
const removeAccount = async () => {
|
||||||
|
setLoadingStatus([true, 'Talking to the backend'])
|
||||||
|
const result = await backend.removeAccount()
|
||||||
|
if (result.success) {
|
||||||
|
setLoadingStatus([true, 'Done. Or rather, gone.', true, true])
|
||||||
|
signOut()
|
||||||
|
} else setLoadingStatus([true, 'An error occured. Please report this', true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.control === 5)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>This button is red for a reason.</p>
|
||||||
|
<IconButton onClick={removeAccount} color="error">
|
||||||
|
<TrashIcon />
|
||||||
|
Remove your FreeSewing account
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
setModal(
|
||||||
|
<ModalWrapper keepOpenOnClick>
|
||||||
|
<div className="text-center w-full">
|
||||||
|
<h2>There is no way back from this</h2>
|
||||||
|
<p>If this is what you want, then go ahead.</p>
|
||||||
|
<IconButton onClick={removeAccount} color="error" className="mx-auto">
|
||||||
|
<TrashIcon />
|
||||||
|
Remove your FreeSewing account
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={clearModal}
|
||||||
|
color="primary"
|
||||||
|
className="mx-auto daisy-btn-outline mt-4"
|
||||||
|
>
|
||||||
|
<ExitIcon />
|
||||||
|
Back to safety
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
Remove your FreeSewing account
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
98
packages/react/components/Account/Restrict.mjs
Normal file
98
packages/react/components/Account/Restrict.mjs
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
// Dependencies
|
||||||
|
import { linkClasses } from '@freesewing/utils'
|
||||||
|
|
||||||
|
// Context
|
||||||
|
import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus'
|
||||||
|
import { ModalContext } from '@freesewing/react/context/Modal'
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import React, { useState, useContext } from 'react'
|
||||||
|
import { useAccount } from '@freesewing/react/hooks/useAccount'
|
||||||
|
import { useBackend } from '@freesewing/react/hooks/useBackend'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import { Link as WebLink } from '@freesewing/react/components/Link'
|
||||||
|
import { BackIcon, NoIcon } from '@freesewing/react/components/Icon'
|
||||||
|
import { Popout } from '@freesewing/react/components/Popout'
|
||||||
|
import { IconButton } from '@freesewing/react/components/Button'
|
||||||
|
import { ModalWrapper } from '@freesewing/react/components/Modal'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Component for the account/actions/restrict page
|
||||||
|
*/
|
||||||
|
export const Restrict = ({ Link = false }) => {
|
||||||
|
if (!Link) Link = WebLink
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
const backend = useBackend()
|
||||||
|
const { signOut, account } = useAccount()
|
||||||
|
|
||||||
|
// Context
|
||||||
|
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
||||||
|
const { setModal, clearModal } = useContext(ModalContext)
|
||||||
|
|
||||||
|
// Helper method to restrict the account
|
||||||
|
const restrictAccount = async () => {
|
||||||
|
setLoadingStatus([true, 'Talking to the backend'])
|
||||||
|
const [status, body] = await backend.restrictAccount()
|
||||||
|
if (status === 200) {
|
||||||
|
setLoadingStatus([true, 'Done. Consider yourself restrcited.', true, true])
|
||||||
|
signOut()
|
||||||
|
} else setLoadingStatus([true, 'An error occured. Please report this', true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.control === 5)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>This button is red for a reason.</p>
|
||||||
|
<IconButton onClick={restrictAccount} color="error">
|
||||||
|
<Nocon />
|
||||||
|
Remove your FreeSewing account
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<p>
|
||||||
|
The GDPR guarantees{' '}
|
||||||
|
<Link href="/docs/about/rights/#the-right-to-restrict-processing" className={linkClasses}>
|
||||||
|
your right to restrict processing
|
||||||
|
</Link>{' '}
|
||||||
|
of your personal data.
|
||||||
|
</p>
|
||||||
|
<p>This will disable your account, but not remove any data.</p>
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
setModal(
|
||||||
|
<ModalWrapper keepOpenOnClick>
|
||||||
|
<div className="text-center w-full max-w-xl">
|
||||||
|
<h2>Proceed with caution</h2>
|
||||||
|
<p>
|
||||||
|
While no data will be removed, this will disable your account. Furthermore, you
|
||||||
|
can not undo this on your own, but will have to contact support when you want to
|
||||||
|
restore access to your account.
|
||||||
|
</p>
|
||||||
|
<IconButton onClick={restrictAccount} color="error" className="mx-auto">
|
||||||
|
<NoIcon stroke={3} />
|
||||||
|
Restrict processing of your FreeSewing data
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={clearModal}
|
||||||
|
color="primary"
|
||||||
|
className="mx-auto daisy-btn-outline mt-4"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
Back to safety
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NoIcon stroke={3} />
|
||||||
|
Restrict processing of your FreeSewing data
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,408 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useEffect, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import { CopyToClipboard } from 'react-copy-to-clipboard'
|
|
||||||
import { shortDate, formatNumber } from 'shared/utils.mjs'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton, DisplayRow, NumberBullet } from './shared.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { LeftIcon, PlusIcon, CopyIcon, RightIcon, TrashIcon } from 'shared/components/icons.mjs'
|
|
||||||
import { Link, linkClasses } from 'shared/components/link.mjs'
|
|
||||||
import { StringInput, ListInput, FormControl } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
const ExpiryPicker = ({ t, expires, setExpires }) => {
|
|
||||||
const router = useRouter()
|
|
||||||
const { locale } = router
|
|
||||||
const [months, setMonths] = useState(1)
|
|
||||||
|
|
||||||
// Run update when component mounts
|
|
||||||
useEffect(() => update(months), [])
|
|
||||||
|
|
||||||
const update = (evt) => {
|
|
||||||
const value = typeof evt === 'number' ? evt : evt.target.value
|
|
||||||
setExpires(DateTime.now().plus({ months: value }))
|
|
||||||
setMonths(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-row gap-2 items-center">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={24}
|
|
||||||
value={months}
|
|
||||||
className="range range-secondary"
|
|
||||||
onChange={update}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Popout note compact>
|
|
||||||
{t('keyExpiresDesc')}
|
|
||||||
<b> {shortDate(locale, expires)}</b>
|
|
||||||
</Popout>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CopyInput = ({ text }) => {
|
|
||||||
const { t } = useTranslation(['status'])
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const showCopied = () => {
|
|
||||||
setCopied(true)
|
|
||||||
setLoadingStatus([true, t('copiedToClipboard'), true, true])
|
|
||||||
window.setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flez-row gap-2 items-center w-full">
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={text}
|
|
||||||
className="input w-full input-bordered flex flex-row"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<CopyToClipboard text={text} onCopy={showCopied}>
|
|
||||||
<button className={`btn ${copied ? 'btn-success' : 'btn-secondary'}`}>
|
|
||||||
<CopyIcon />
|
|
||||||
</button>
|
|
||||||
</CopyToClipboard>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Apikey = ({ apikey, setId }) => {
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const router = useRouter()
|
|
||||||
const { locale } = router
|
|
||||||
|
|
||||||
return apikey ? (
|
|
||||||
<div>
|
|
||||||
<DisplayRow title={t('keyName')}>{apikey.name}</DisplayRow>
|
|
||||||
<DisplayRow title={t('created')}>{shortDate(locale, apikey.createdAt)}</DisplayRow>
|
|
||||||
<DisplayRow title={t('expires')}>{shortDate(locale, apikey.expiresAt)}</DisplayRow>
|
|
||||||
<DisplayRow title="Key ID">{apikey.key}</DisplayRow>
|
|
||||||
<div className="flex flex-row flex-wrap md:gap-2 md:items-center md:justify-between mt-8">
|
|
||||||
<button
|
|
||||||
className="w-full md:w-auto btn btn-secondary pr-6 flex flex-row items-center gap-2"
|
|
||||||
onClick={() => setId(null)}
|
|
||||||
>
|
|
||||||
<LeftIcon />
|
|
||||||
{t('apikeys')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShowKey = ({ apikey, t, clear }) => {
|
|
||||||
const router = useRouter()
|
|
||||||
const { locale } = router
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Popout warning compact>
|
|
||||||
{t('keySecretWarning')}
|
|
||||||
</Popout>
|
|
||||||
<DisplayRow title={t('keyName')}>{apikey.name}</DisplayRow>
|
|
||||||
<DisplayRow title={t('created')}>{shortDate(locale, apikey.createdAt)}</DisplayRow>
|
|
||||||
<DisplayRow title={t('created')}>{shortDate(locale, apikey.expiresAt)}</DisplayRow>
|
|
||||||
<DisplayRow title="Key ID">
|
|
||||||
<CopyInput text={apikey.key} />
|
|
||||||
</DisplayRow>
|
|
||||||
<DisplayRow title="Key Secret">
|
|
||||||
<CopyInput text={apikey.secret} />
|
|
||||||
</DisplayRow>
|
|
||||||
<div className="flex flex-row flex-wrap md:gap-2 md:items-center md:justify-between mt-8">
|
|
||||||
<button
|
|
||||||
className="w-full md:w-auto btn btn-secondary pr-6 flex flex-row items-center gap-2"
|
|
||||||
onClick={() => router.push('/account/apikeys')}
|
|
||||||
>
|
|
||||||
<LeftIcon />
|
|
||||||
{t('apikeys')}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary w-full mt-2 md:w-auto md:mt-0" onClick={clear}>
|
|
||||||
<PlusIcon />
|
|
||||||
{t('newApikey')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const NewKey = ({ account, setGenerate, backend }) => {
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [level, setLevel] = useState(1)
|
|
||||||
const [expires, setExpires] = useState(Date.now())
|
|
||||||
const [apikey, setApikey] = useState(false)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const docs = {}
|
|
||||||
for (const option of ['name', 'expiry', 'level']) {
|
|
||||||
docs[option] = (
|
|
||||||
<DynamicMdx language={i18n.language} slug={`docs/about/site/apikeys/${option}`} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const levels = account.role === 'admin' ? [0, 1, 2, 3, 4, 5, 6, 7, 8] : [0, 1, 2, 3, 4]
|
|
||||||
|
|
||||||
const createKey = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.createApikey({
|
|
||||||
name,
|
|
||||||
level,
|
|
||||||
expiresIn: Math.floor((expires.valueOf() - Date.now().valueOf()) / 1000),
|
|
||||||
})
|
|
||||||
if (result.success) {
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
setApikey(result.data.apikey)
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
setApikey(false)
|
|
||||||
setGenerate(false)
|
|
||||||
setName('')
|
|
||||||
setLevel(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{apikey ? (
|
|
||||||
<ShowKey {...{ apikey, t, clear }} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<StringInput
|
|
||||||
id="apikey-name"
|
|
||||||
label={t('keyName')}
|
|
||||||
docs={docs.name}
|
|
||||||
current={name}
|
|
||||||
update={setName}
|
|
||||||
valid={(val) => val.length > 0}
|
|
||||||
placeholder={'Alicia Key'}
|
|
||||||
/>
|
|
||||||
<FormControl label={t('keyExpires')} docs={docs.expiry}>
|
|
||||||
<ExpiryPicker {...{ t, expires, setExpires }} />
|
|
||||||
</FormControl>
|
|
||||||
<ListInput
|
|
||||||
id="apikey-level"
|
|
||||||
label={t('keyLevel')}
|
|
||||||
docs={docs.level}
|
|
||||||
list={levels.map((l) => ({
|
|
||||||
val: l,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center w-full justify-between">
|
|
||||||
<span>{t(`keyLevel${l}`)}</span>
|
|
||||||
<NumberBullet nr={l} color="secondary" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
current={level}
|
|
||||||
update={setLevel}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-row gap-2 items-center w-full my-8">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary capitalize w-full md:w-auto"
|
|
||||||
disabled={name.length < 1}
|
|
||||||
onClick={createKey}
|
|
||||||
>
|
|
||||||
{t('newApikey')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component for the 'new/apikey' page
|
|
||||||
export const NewApikey = () => {
|
|
||||||
// Hooks
|
|
||||||
const { account } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [generate, setGenerate] = useState(false)
|
|
||||||
const [added, setAdded] = useState(0)
|
|
||||||
|
|
||||||
// Helper method to force refresh
|
|
||||||
const keyAdded = () => setAdded(added + 1)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl xl:pl-4">
|
|
||||||
<NewKey
|
|
||||||
{...{
|
|
||||||
account,
|
|
||||||
generate,
|
|
||||||
setGenerate,
|
|
||||||
backend,
|
|
||||||
keyAdded,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component for the account/apikeys page
|
|
||||||
export const Apikeys = ({ setId }) => {
|
|
||||||
const router = useRouter()
|
|
||||||
const { locale } = router
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const { account } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus, LoadingProgress } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [keys, setKeys] = useState([])
|
|
||||||
const [selected, setSelected] = useState({})
|
|
||||||
const [refresh, setRefresh] = useState(0)
|
|
||||||
|
|
||||||
// Helper var to see how many are selected
|
|
||||||
const selCount = Object.keys(selected).length
|
|
||||||
|
|
||||||
// Effects
|
|
||||||
useEffect(() => {
|
|
||||||
const getApikeys = async () => {
|
|
||||||
const result = await backend.getApikeys()
|
|
||||||
if (result.success) setKeys(result.data.apikeys)
|
|
||||||
}
|
|
||||||
getApikeys()
|
|
||||||
}, [refresh])
|
|
||||||
|
|
||||||
// Helper method to toggle single selection
|
|
||||||
const toggleSelect = (id) => {
|
|
||||||
const newSelected = { ...selected }
|
|
||||||
if (newSelected[id]) delete newSelected[id]
|
|
||||||
else newSelected[id] = 1
|
|
||||||
setSelected(newSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to toggle select all
|
|
||||||
const toggleSelectAll = () => {
|
|
||||||
if (selCount === keys.length) setSelected({})
|
|
||||||
else {
|
|
||||||
const newSelected = {}
|
|
||||||
for (const key of keys) newSelected[key.id] = 1
|
|
||||||
setSelected(newSelected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to delete one or more apikeys
|
|
||||||
const removeSelectedApikeys = async () => {
|
|
||||||
let i = 0
|
|
||||||
for (const key in selected) {
|
|
||||||
i++
|
|
||||||
await backend.removeApikey(key)
|
|
||||||
setLoadingStatus([
|
|
||||||
true,
|
|
||||||
<LoadingProgress val={i} max={selCount} msg={t('removingApikeys')} key="linter" />,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
setSelected({})
|
|
||||||
setRefresh(refresh + 1)
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl xl:pl-4">
|
|
||||||
<p className="text-center md:text-right">
|
|
||||||
<Link
|
|
||||||
className="btn btn-primary capitalize w-full md:w-auto"
|
|
||||||
bottom
|
|
||||||
primary
|
|
||||||
href="/new/apikey"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
{t('newApikey')}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<button className="btn btn-error" onClick={removeSelectedApikeys} disabled={selCount < 1}>
|
|
||||||
<TrashIcon /> {selCount} {t('apikeys')}
|
|
||||||
</button>
|
|
||||||
<table className="table table-auto">
|
|
||||||
<thead className="border border-base-300 border-b-2 border-t-0 border-x-0">
|
|
||||||
<tr className="b">
|
|
||||||
<th className="text-base-300 text-base">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-secondary"
|
|
||||||
onClick={toggleSelectAll}
|
|
||||||
checked={keys.length === selCount}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th className="text-base-300 text-base">{t('keyName')}</th>
|
|
||||||
<th className="text-base-300 text-base">
|
|
||||||
<span className="hidden md:inline">{t('keyLevel')}</span>
|
|
||||||
<span role="img" className="inline md:hidden">
|
|
||||||
🔐
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th className="text-base-300 text-base">{t('keyExpires')}</th>
|
|
||||||
<th className="text-base-300 text-base hidden md:block">{t('apiCalls')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{keys.map((apikey, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected[apikey.id] ? true : false}
|
|
||||||
className="checkbox checkbox-secondary"
|
|
||||||
onClick={() => toggleSelect(apikey.id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
<button className={linkClasses} onClick={() => setId(apikey.id)}>
|
|
||||||
{apikey.name}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
{apikey.level}
|
|
||||||
<small className="hidden md:inline pl-2 text-base-300 italic">
|
|
||||||
({t(`keyLevel${apikey.level}`)})
|
|
||||||
</small>
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
{shortDate(locale, apikey.expiresAt, false)}
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium hidden md:block">
|
|
||||||
{formatNumber(apikey.calls)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<BackToAccountButton />
|
|
||||||
{account.control < 5 ? (
|
|
||||||
<Popout link>
|
|
||||||
<h5>{t('keyDocsTitle')}</h5>
|
|
||||||
<p>{t('keyDocsMsg')}</p>
|
|
||||||
<p className="text-right">
|
|
||||||
<a
|
|
||||||
className="btn btn-secondary mt-2"
|
|
||||||
href="https://freesewing.dev/reference/backend/apikeys"
|
|
||||||
>
|
|
||||||
FreeSewing.dev
|
|
||||||
<RightIcon />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</Popout>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,98 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { Icons, welcomeSteps, BackToAccountButton } from './shared.mjs'
|
|
||||||
import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs'
|
|
||||||
import { ContinueButton } from 'shared/components/buttons/continue-button.mjs'
|
|
||||||
import { MarkdownInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
import { TipIcon } from 'shared/components/icons.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const Tab = ({ id, activeTab, setActiveTab, t }) => (
|
|
||||||
<button
|
|
||||||
className={`text-xl font-bold capitalize tab tab-bordered grow
|
|
||||||
${activeTab === id ? 'tab-active' : ''}`}
|
|
||||||
onClick={() => setActiveTab(id)}
|
|
||||||
>
|
|
||||||
{t(id)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const BioSettings = ({ welcome = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [bio, setBio] = useState(account.bio)
|
|
||||||
|
|
||||||
// Helper method to save bio
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ bio })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next step in the onboarding
|
|
||||||
const nextHref =
|
|
||||||
welcomeSteps[account.control].length > 5
|
|
||||||
? '/welcome/' + welcomeSteps[account.control][6]
|
|
||||||
: '/docs/about/guide'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl xl:pl-4">
|
|
||||||
<MarkdownInput
|
|
||||||
id="account-bio"
|
|
||||||
label={t('bioTitle')}
|
|
||||||
update={setBio}
|
|
||||||
current={bio}
|
|
||||||
placeholder={t('bioTitle')}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/bio`} />}
|
|
||||||
labelBL={
|
|
||||||
<span className="flex flex-row items-center gap-1">
|
|
||||||
<TipIcon className="w-6 h-6 text-success" />
|
|
||||||
{t('mdSupport')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SaveSettingsButton btnProps={{ onClick: save }} welcome={welcome} />
|
|
||||||
{!welcome && <BackToAccountButton />}
|
|
||||||
|
|
||||||
{welcome ? (
|
|
||||||
<>
|
|
||||||
<ContinueButton btnProps={{ href: nextHref }} link />
|
|
||||||
{welcomeSteps[account.control].length > 0 ? (
|
|
||||||
<>
|
|
||||||
<progress
|
|
||||||
className="progress progress-primary w-full mt-12"
|
|
||||||
value={600 / welcomeSteps[account.control].length}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<span className="pt-4 text-sm font-bold opacity-50">
|
|
||||||
6 / {welcomeSteps[account.control].length}
|
|
||||||
</span>
|
|
||||||
<Icons
|
|
||||||
done={welcomeSteps[account.control].slice(0, 5)}
|
|
||||||
todo={welcomeSteps[account.control].slice(6)}
|
|
||||||
current="bio"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,98 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { Icons, welcomeSteps, BackToAccountButton } from './shared.mjs'
|
|
||||||
import { ContinueButton } from 'shared/components/buttons/continue-button.mjs'
|
|
||||||
import { ListInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { OkIcon, NoIcon } from 'shared/components/icons.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const CompareSettings = ({ welcome = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [selection, setSelection] = useState(account?.compare ? 'yes' : 'no')
|
|
||||||
|
|
||||||
// Helper method to update the account
|
|
||||||
const update = async (val) => {
|
|
||||||
if (val !== selection) {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({
|
|
||||||
compare: val === 'yes' ? true : false,
|
|
||||||
})
|
|
||||||
if (result.success) {
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setSelection(val)
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link to the next onboarding step
|
|
||||||
const nextHref =
|
|
||||||
welcomeSteps[account?.control].length > 3
|
|
||||||
? '/welcome/' + welcomeSteps[account?.control][4]
|
|
||||||
: '/docs/about/guide'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<ListInput
|
|
||||||
id="account-compare"
|
|
||||||
label={t('compareTitle')}
|
|
||||||
list={['yes', 'no'].map((val) => ({
|
|
||||||
val,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center w-full justify-between">
|
|
||||||
<span>{t(val === 'yes' ? 'compareYes' : 'compareNo')}</span>
|
|
||||||
{val === 'yes' ? (
|
|
||||||
<OkIcon className="w-8 h-8 text-success" stroke={4} />
|
|
||||||
) : (
|
|
||||||
<NoIcon className="w-8 h-8 text-error" stroke={3} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
desc: t(val === 'yes' ? 'compareYesd' : 'compareNod'),
|
|
||||||
}))}
|
|
||||||
current={selection}
|
|
||||||
update={update}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/compare`} />}
|
|
||||||
/>
|
|
||||||
{welcome ? (
|
|
||||||
<>
|
|
||||||
<ContinueButton btnProps={{ href: nextHref }} link />
|
|
||||||
{welcomeSteps[account?.control].length > 0 ? (
|
|
||||||
<>
|
|
||||||
<progress
|
|
||||||
className="progress progress-primary w-full mt-12"
|
|
||||||
value={400 / welcomeSteps[account?.control].length}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<span className="pt-4 text-sm font-bold opacity-50">
|
|
||||||
4 / {welcomeSteps[account?.control].length}
|
|
||||||
</span>
|
|
||||||
<Icons
|
|
||||||
done={welcomeSteps[account?.control].slice(0, 3)}
|
|
||||||
todo={welcomeSteps[account?.control].slice(4)}
|
|
||||||
current="compare"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<BackToAccountButton />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,116 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { nsMerge } from 'shared/utils.mjs'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs'
|
|
||||||
import { GdprAccountDetails, ns as gdprNs } from 'shared/components/gdpr/details.mjs'
|
|
||||||
|
|
||||||
export const ns = nsMerge(gdprNs, 'account', 'status')
|
|
||||||
|
|
||||||
const Checkbox = ({ value, setter, label, children = null }) => (
|
|
||||||
<div
|
|
||||||
className={`form-control p-4 hover:cursor-pointer rounded border-l-8 my-2
|
|
||||||
${value ? 'border-success bg-success' : 'border-error bg-error'}
|
|
||||||
bg-opacity-10 shadow`}
|
|
||||||
onClick={() => setter(value ? false : true)}
|
|
||||||
>
|
|
||||||
<div className="form-control flex flex-row items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox"
|
|
||||||
checked={value ? 'checked' : ''}
|
|
||||||
onChange={() => setter(value ? false : true)}
|
|
||||||
/>
|
|
||||||
<span className="label-text">{label}</span>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const ConsentSettings = ({ title = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount, setToken } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [consent1, setConsent1] = useState(account?.consent > 0)
|
|
||||||
const [consent2, setConsent2] = useState(account?.consent > 1)
|
|
||||||
|
|
||||||
// Helper method to update the account
|
|
||||||
const update = async () => {
|
|
||||||
let newConsent = 0
|
|
||||||
if (consent1) newConsent = 1
|
|
||||||
if (consent1 && consent2) newConsent = 2
|
|
||||||
if (newConsent !== account.consent) {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ consent: newConsent })
|
|
||||||
if (result.data?.result === 'success') {
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
setAccount(result.data.account)
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to remove the account
|
|
||||||
const removeAccount = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.removeAccount()
|
|
||||||
if (result === true) setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
setToken(null)
|
|
||||||
setAccount({ username: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl xl:pl-4">
|
|
||||||
{title ? <h2 className="text-4xl">{t('privacyMatters')}</h2> : null}
|
|
||||||
<p>{t('compliant')}</p>
|
|
||||||
<p>{t('consentWhyAnswer')}</p>
|
|
||||||
<h5 className="mt-8">{t('accountQuestion')}</h5>
|
|
||||||
<GdprAccountDetails />
|
|
||||||
{consent1 ? (
|
|
||||||
<Checkbox value={consent1} setter={setConsent1} label={t('yesIDo')} />
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-lg w-full mt-4"
|
|
||||||
onClick={() => setConsent1(!consent1)}
|
|
||||||
>
|
|
||||||
{t('clickHere')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{consent1 ? (
|
|
||||||
<Checkbox value={consent2} setter={setConsent2} label={t('openDataQuestion')} />
|
|
||||||
) : null}
|
|
||||||
{consent1 && !consent2 ? <Popout note>{t('openDataInfo')}</Popout> : null}
|
|
||||||
{!consent1 && <Popout warning>{t('noConsentNoAccount')}</Popout>}
|
|
||||||
{consent1 ? (
|
|
||||||
<SaveSettingsButton btnProps={{ onClick: update }} />
|
|
||||||
) : (
|
|
||||||
<SaveSettingsButton
|
|
||||||
label={t('account:removeAccount')}
|
|
||||||
btnProps={{
|
|
||||||
onClick: removeAccount,
|
|
||||||
className: 'btn mt-4 capitalize w-full btn-error',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<BackToAccountButton />
|
|
||||||
<p className="text-center opacity-50 mt-12">
|
|
||||||
<Link href="/docs/about/privacy" className="hover:text-secondary underline">
|
|
||||||
FreeSewing Privacy Notice
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,105 +0,0 @@
|
||||||
// __SDEFILE__ - This file is a dependency for the stand-alone environment
|
|
||||||
// Dependencies
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton, Icons, welcomeSteps } from './shared.mjs'
|
|
||||||
import { ContinueButton } from 'shared/components/buttons/continue-button.mjs'
|
|
||||||
import { ListInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { ControlScore } from 'shared/components/control/score.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
/** state handlers for any input that changes the control setting */
|
|
||||||
export const useControlState = () => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount, token } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [selection, setSelection] = useState(account.control)
|
|
||||||
|
|
||||||
// Method to update the control setting
|
|
||||||
const update = async (control) => {
|
|
||||||
if (control !== selection) {
|
|
||||||
if (token) {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ control })
|
|
||||||
if (result.success) {
|
|
||||||
setSelection(control)
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
}
|
|
||||||
//fallback for guest users
|
|
||||||
else {
|
|
||||||
setAccount({ ...account, control })
|
|
||||||
setSelection(control)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { selection, update }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ControlSettings = ({ welcome = false, noBack = false }) => {
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
|
|
||||||
const { selection, update } = useControlState()
|
|
||||||
|
|
||||||
// Helper to get the link to the next onboarding step
|
|
||||||
const nextHref = welcome
|
|
||||||
? welcomeSteps[selection].length > 1
|
|
||||||
? '/welcome/' + welcomeSteps[selection][1]
|
|
||||||
: '/docs/about/guide'
|
|
||||||
: false
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<ListInput
|
|
||||||
id="account-control"
|
|
||||||
label={t('controlTitle')}
|
|
||||||
list={[1, 2, 3, 4, 5].map((val) => ({
|
|
||||||
val,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center w-full justify-between">
|
|
||||||
<span>{t(`control${val}.t`)}</span>
|
|
||||||
<ControlScore control={val} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
desc: t(`control${val}.d`),
|
|
||||||
}))}
|
|
||||||
current={selection}
|
|
||||||
update={update}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug="docs/about/site/account/control" />}
|
|
||||||
/>
|
|
||||||
{welcome ? (
|
|
||||||
<>
|
|
||||||
<ContinueButton btnProps={{ href: nextHref }} link />
|
|
||||||
{welcomeSteps[selection].length > 1 ? (
|
|
||||||
<>
|
|
||||||
<progress
|
|
||||||
className="progress progress-primary w-full mt-12"
|
|
||||||
value={100 / welcomeSteps[selection].length}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<span className="pt-4 text-sm font-bold opacity-50">
|
|
||||||
1 / {welcomeSteps[selection].length}
|
|
||||||
</span>
|
|
||||||
<Icons done={[]} todo={welcomeSteps[selection].slice(1)} current="" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : noBack ? null : (
|
|
||||||
<BackToAccountButton />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,76 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Verification methods
|
|
||||||
import { validateEmail, validateTld } from 'shared/utils.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { EmailInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const EmailSettings = () => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [email, setEmail] = useState(account.email)
|
|
||||||
const [changed, setChanged] = useState(false)
|
|
||||||
|
|
||||||
// Helper method to update account
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ email })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
setChanged(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is email valid?
|
|
||||||
const valid = (validateEmail(email) && validateTld(email)) || false
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
{changed ? (
|
|
||||||
<Popout note>
|
|
||||||
<h3>{t('oneMoreThing')}</h3>
|
|
||||||
<p>{t('emailChangeConfirmation')}</p>
|
|
||||||
</Popout>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<EmailInput
|
|
||||||
id="account-email"
|
|
||||||
label={t('account:email')}
|
|
||||||
placeholder={t('account:email')}
|
|
||||||
update={setEmail}
|
|
||||||
labelBL={t('emailTitle')}
|
|
||||||
current={email}
|
|
||||||
original={account.email}
|
|
||||||
valid={() => valid}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/email`} />}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn mt-4 btn-primary w-full"
|
|
||||||
onClick={save}
|
|
||||||
disabled={!valid || email.toLowerCase() === account.email}
|
|
||||||
>
|
|
||||||
{t('save')}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { WebLink } from 'shared/components/link.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const ExportAccount = () => {
|
|
||||||
// Hooks
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
const [link, setLink] = useState()
|
|
||||||
|
|
||||||
// Helper method to export account
|
|
||||||
const exportData = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.exportAccount()
|
|
||||||
if (result.success) {
|
|
||||||
setLink(result.data.data)
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
{link ? (
|
|
||||||
<Popout link>
|
|
||||||
<h5>{t('exportDownload')}</h5>
|
|
||||||
<p className="text-lg">
|
|
||||||
<WebLink href={link} txt={link} />
|
|
||||||
</p>
|
|
||||||
</Popout>
|
|
||||||
) : null}
|
|
||||||
<p>{t('exportMsg')}</p>
|
|
||||||
<button className="btn btn-primary capitalize w-full my-2" onClick={exportData}>
|
|
||||||
{t('export')}
|
|
||||||
</button>
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
|
|
||||||
export const ForceAccountCheck = ({ trigger = null }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount, signOut } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [lastCheck, setLastCheck] = useState(Date.now())
|
|
||||||
|
|
||||||
// The actual check
|
|
||||||
useEffect(() => {
|
|
||||||
const age = Date.now() - lastCheck
|
|
||||||
if (account.status && age < 500) {
|
|
||||||
const checkAccount = async () => {
|
|
||||||
const result = await backend.reloadAccount()
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
} else {
|
|
||||||
// Login expired. Logout user.
|
|
||||||
signOut()
|
|
||||||
}
|
|
||||||
setLastCheck(Date.now())
|
|
||||||
}
|
|
||||||
checkAccount()
|
|
||||||
}
|
|
||||||
}, [trigger])
|
|
||||||
|
|
||||||
// Don't return anything. This is all about the useEffect hook.
|
|
||||||
return null
|
|
||||||
}
|
|
|
@ -1,62 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs'
|
|
||||||
import { StringInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const GithubSettings = () => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [githubUsername, setGithubUsername] = useState(account.data.githubUsername || '')
|
|
||||||
const [githubEmail, setGithubEmail] = useState(account.data.githubEmail || '')
|
|
||||||
|
|
||||||
// Helper method to save changes
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ data: { githubUsername, githubEmail } })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<h2 className="text-4xl">{t('githubTitle')}</h2>
|
|
||||||
<StringInput
|
|
||||||
id="account-github-email"
|
|
||||||
label={t('email')}
|
|
||||||
current={githubEmail}
|
|
||||||
update={setGithubEmail}
|
|
||||||
valid={(val) => val.length > 0}
|
|
||||||
placeholder={'joost@joost.at'}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/github`} />}
|
|
||||||
/>
|
|
||||||
<StringInput
|
|
||||||
id="account-github-username"
|
|
||||||
label={t('username')}
|
|
||||||
current={githubUsername}
|
|
||||||
update={setGithubUsername}
|
|
||||||
valid={(val) => val.length > 0}
|
|
||||||
placeholder={'joostdecock'}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/github`} />}
|
|
||||||
/>
|
|
||||||
<SaveSettingsButton btnProps={{ onClick: save }} />
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,88 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { cloudflareImageUrl } from 'shared/utils.mjs'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { Icons, welcomeSteps, BackToAccountButton } from './shared.mjs'
|
|
||||||
import { ContinueButton } from 'shared/components/buttons/continue-button.mjs'
|
|
||||||
import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs'
|
|
||||||
import { PassiveImageInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const ImgSettings = ({ welcome = false }) => {
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
|
|
||||||
const [img, setImg] = useState('')
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ img })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextHref = '/docs/about/guide'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
{!welcome || img !== false ? (
|
|
||||||
<img
|
|
||||||
alt="img"
|
|
||||||
src={img || cloudflareImageUrl({ id: `uid-${account.ihash}`, variant: 'public' })}
|
|
||||||
className="shadow mb-4"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<PassiveImageInput
|
|
||||||
id="account-img"
|
|
||||||
label={t('image')}
|
|
||||||
placeholder={'image'}
|
|
||||||
update={setImg}
|
|
||||||
current={img}
|
|
||||||
valid={(val) => val.length > 0}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/img`} />}
|
|
||||||
/>
|
|
||||||
{welcome ? (
|
|
||||||
<>
|
|
||||||
<button className={`btn btn-secondary mt-4 px-8`} onClick={save} disabled={!img}>
|
|
||||||
{t('save')}
|
|
||||||
</button>
|
|
||||||
<ContinueButton btnProps={{ href: nextHref }} link />
|
|
||||||
{welcomeSteps[account.control].length > 0 ? (
|
|
||||||
<>
|
|
||||||
<progress
|
|
||||||
className="progress progress-primary w-full mt-12"
|
|
||||||
value={700 / welcomeSteps[account.control].length}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<span className="pt-4 text-sm font-bold opacity-50">
|
|
||||||
7 / {welcomeSteps[account.control].length}
|
|
||||||
</span>
|
|
||||||
<Icons
|
|
||||||
done={welcomeSteps[account.control].slice(0, 6)}
|
|
||||||
todo={welcomeSteps[account.control].slice(7)}
|
|
||||||
current="img"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SaveSettingsButton btnProps={{ onClick: save }} />
|
|
||||||
<BackToAccountButton />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { Icons, welcomeSteps, BackToAccountButton, NumberBullet } from './shared.mjs'
|
|
||||||
import { ContinueButton } from 'shared/components/buttons/continue-button.mjs'
|
|
||||||
import { ListInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const ImperialSettings = ({ welcome = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [selection, setSelection] = useState(account?.imperial === true ? 'imperial' : 'metric')
|
|
||||||
|
|
||||||
// Helper method to update account
|
|
||||||
const update = async (val) => {
|
|
||||||
if (val !== selection) {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ imperial: val === 'imperial' ? true : false })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setSelection(val)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next step in the onboarding
|
|
||||||
const nextHref =
|
|
||||||
welcomeSteps[account?.control].length > 3
|
|
||||||
? '/welcome/' + welcomeSteps[account?.control][3]
|
|
||||||
: '/docs/about/guide'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<ListInput
|
|
||||||
id="account-units"
|
|
||||||
label={t('unitsTitle')}
|
|
||||||
list={['metric', 'imperial'].map((val) => ({
|
|
||||||
val,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center w-full justify-between">
|
|
||||||
<span>{t(`${val}Units`)}</span>
|
|
||||||
<NumberBullet nr={val === 'imperial' ? '″' : 'cm'} color="secondary" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
desc: t(`${val}Unitsd`),
|
|
||||||
}))}
|
|
||||||
current={selection}
|
|
||||||
update={update}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/units`} />}
|
|
||||||
/>
|
|
||||||
{welcome ? (
|
|
||||||
<>
|
|
||||||
<ContinueButton btnProps={{ href: nextHref }} link />
|
|
||||||
{welcomeSteps[account?.control].length > 0 ? (
|
|
||||||
<>
|
|
||||||
<progress
|
|
||||||
className="progress progress-primary w-full mt-12"
|
|
||||||
value={300 / welcomeSteps[account?.control].length}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<span className="pt-4 text-sm font-bold opacity-50">
|
|
||||||
3 / {welcomeSteps[account?.control].length}
|
|
||||||
</span>
|
|
||||||
<Icons
|
|
||||||
done={welcomeSteps[account?.control].slice(0, 2)}
|
|
||||||
todo={welcomeSteps[account?.control].slice(3)}
|
|
||||||
current="units"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<BackToAccountButton />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { FileInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { Yaml } from 'shared/components/yaml.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { linkClasses } from 'shared/components/link.mjs'
|
|
||||||
import yaml from 'yaml'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const Importer = () => {
|
|
||||||
// Hooks
|
|
||||||
const { account } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// Helper method to upload/save a set
|
|
||||||
const uploadSet = async (upload) => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
const chunks = upload.split(',')
|
|
||||||
if (chunks[0].includes('json')) data = JSON.parse(atob(chunks[1]))
|
|
||||||
else data = yaml.parse(atob(chunks[1]))
|
|
||||||
if (!Array.isArray(data)) data = [data]
|
|
||||||
/*
|
|
||||||
* Treat each set
|
|
||||||
*/
|
|
||||||
for (const set of data) {
|
|
||||||
if (set.measurements || set.measies) {
|
|
||||||
const name = set.name || 'J. Doe'
|
|
||||||
setLoadingStatus([true, `Importing ${name}`])
|
|
||||||
const result = await backend.createSet({
|
|
||||||
name: set.name || 'J. Doe',
|
|
||||||
units: set.units || 'metric',
|
|
||||||
notes: set.notes || '',
|
|
||||||
measies: set.measurements || set.measies,
|
|
||||||
userId: account.id,
|
|
||||||
})
|
|
||||||
if (result.success) setLoadingStatus([true, `Imported ${name}`, true, true])
|
|
||||||
else setLoadingStatus([true, `Import of ${name} failed`, true, false])
|
|
||||||
} else {
|
|
||||||
setLoadingStatus([true, `Invalid format`, true, false])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
setLoadingStatus([true, `Import of ${name || 'file'} failed`, true, false])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl xl:pl-4">
|
|
||||||
<p>{t('account:importHere')}</p>
|
|
||||||
<p>{t('account:importSupported')}</p>
|
|
||||||
<ul className="list list-inside list-disc ml-4">
|
|
||||||
<li>
|
|
||||||
<a href="#set" className={linkClasses}>
|
|
||||||
{t('account:sets')}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<h2 id="set">{t('account:importSets')}</h2>
|
|
||||||
<FileInput
|
|
||||||
label={`${t('account:measieFile')} (YAML / JSON)`}
|
|
||||||
update={uploadSet}
|
|
||||||
current=""
|
|
||||||
id="file"
|
|
||||||
dropzoneConfig={{
|
|
||||||
accept: {
|
|
||||||
'application/json': ['.json'],
|
|
||||||
'application/yaml': ['.yaml', '.yml'],
|
|
||||||
},
|
|
||||||
maxFiles: 1,
|
|
||||||
multiple: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Popout tip>
|
|
||||||
<p>{t('account:importSetTip1')}</p>
|
|
||||||
<Yaml
|
|
||||||
js={{
|
|
||||||
name: 'Joost',
|
|
||||||
units: 'metric',
|
|
||||||
notes: '',
|
|
||||||
measurements: { biceps: 335, wrist: 190 },
|
|
||||||
}}
|
|
||||||
title="measurements.yaml"
|
|
||||||
/>
|
|
||||||
<p>{t('account:importSetTip2')}</p>
|
|
||||||
</Popout>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -20,6 +20,11 @@ import { Newsletter } from './Newsletter.mjs'
|
||||||
import { Consent } from './Consent.mjs'
|
import { Consent } from './Consent.mjs'
|
||||||
import { Password } from './Password.mjs'
|
import { Password } from './Password.mjs'
|
||||||
import { Mfa } from './Mfa.mjs'
|
import { Mfa } from './Mfa.mjs'
|
||||||
|
import { ImportSet } from './Import.mjs'
|
||||||
|
import { Export } from './Export.mjs'
|
||||||
|
import { Reload } from './Reload.mjs'
|
||||||
|
import { Remove } from './Remove.mjs'
|
||||||
|
import { Restrict } from './Restrict.mjs'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Bookmarks,
|
Bookmarks,
|
||||||
|
@ -51,4 +56,9 @@ export {
|
||||||
Consent,
|
Consent,
|
||||||
Password,
|
Password,
|
||||||
Mfa,
|
Mfa,
|
||||||
|
ImportSet,
|
||||||
|
Export,
|
||||||
|
Reload,
|
||||||
|
Remove,
|
||||||
|
Restrict,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton, NumberBullet } from './shared.mjs'
|
|
||||||
import { ListInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
// Config
|
|
||||||
import { siteConfig as conf } from 'site/site.config.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'locales', 'status']
|
|
||||||
|
|
||||||
export const LanguageSettings = () => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [language, setLanguage] = useState(account.language || 'en')
|
|
||||||
|
|
||||||
// Helper method to update the account
|
|
||||||
const update = async (lang) => {
|
|
||||||
if (lang !== language) {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
setLanguage(lang)
|
|
||||||
const result = await backend.updateAccount({ language: lang })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<ListInput
|
|
||||||
id="account-language"
|
|
||||||
label={t('languageTitle')}
|
|
||||||
list={conf.languages.map((val) => ({
|
|
||||||
val,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center w-full justify-between">
|
|
||||||
<span>
|
|
||||||
{t(`locales:${val}`)}
|
|
||||||
<span className="px-2 opacity-50">|</span>
|
|
||||||
{t(`locales:${val}`, { lng: val })}
|
|
||||||
</span>
|
|
||||||
<NumberBullet nr={val} color="secondary" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
desc: t('languageTitle', { lng: val }),
|
|
||||||
}))}
|
|
||||||
current={language}
|
|
||||||
update={update}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/language`} />}
|
|
||||||
/>
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,187 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { Bullet } from 'shared/components/bullet.mjs'
|
|
||||||
import { PasswordInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { CopyToClipboard } from 'shared/components/copy-to-clipboard.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account']
|
|
||||||
|
|
||||||
const CodeInput = ({ code, setCode, t }) => (
|
|
||||||
<input
|
|
||||||
value={code}
|
|
||||||
onChange={(evt) => setCode(evt.target.value)}
|
|
||||||
className="input w-full text-4xl input-bordered input-lg flex flex-row text-center mb-8 tracking-widest"
|
|
||||||
type="text"
|
|
||||||
placeholder={t('000000')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const MfaSettings = ({ title = false, welcome = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [enable, setEnable] = useState(false)
|
|
||||||
const [disable, setDisable] = useState(false)
|
|
||||||
const [code, setCode] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [scratchCodes, setScratchCodes] = useState(false)
|
|
||||||
|
|
||||||
// Helper method to enable MFA
|
|
||||||
const enableMfa = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.enableMfa()
|
|
||||||
if (result.success) {
|
|
||||||
setEnable(result.data.mfa)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to disable MFA
|
|
||||||
const disableMfa = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.disableMfa({
|
|
||||||
mfa: false,
|
|
||||||
password,
|
|
||||||
token: code,
|
|
||||||
})
|
|
||||||
if (result) {
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
setDisable(false)
|
|
||||||
setEnable(false)
|
|
||||||
setCode('')
|
|
||||||
setPassword('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to confirm MFA
|
|
||||||
const confirmMfa = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.confirmMfa({
|
|
||||||
mfa: true,
|
|
||||||
secret: enable.secret,
|
|
||||||
token: code,
|
|
||||||
})
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setScratchCodes(result.data.scratchCodes)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
setEnable(false)
|
|
||||||
setCode('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out what title to use
|
|
||||||
let titleText = account.mfaEnabled ? t('mfaEnabled') : t('mfaDisabled')
|
|
||||||
if (enable) titleText = t('mfaSetup')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
{title ? <h2 className="text-4xl">{titleText}</h2> : null}
|
|
||||||
{enable ? (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-row items-center justify-center px-8 lg:px-36">
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: enable.qrcode }} />
|
|
||||||
</div>
|
|
||||||
<p className="flex flex-row items-center justify-center">{enable.secret}</p>
|
|
||||||
<Bullet num="1">{t('mfaAdd')}</Bullet>
|
|
||||||
<Bullet num="2">{t('confirmWithMfa')}</Bullet>
|
|
||||||
<input
|
|
||||||
value={code}
|
|
||||||
onChange={(evt) => setCode(evt.target.value)}
|
|
||||||
className="input w-64 m-auto text-4xl input-bordered input-lg flex flex-row text-center mb-8 tracking-widest"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
placeholder={t('000000')}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-success btn-lg block w-full" onClick={confirmMfa}>
|
|
||||||
{t('enableMfa')}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{disable ? (
|
|
||||||
<div className="my-8">
|
|
||||||
<Bullet num="1">
|
|
||||||
<h5>{t('confirmWithPassword')}</h5>
|
|
||||||
<PasswordInput
|
|
||||||
current={password}
|
|
||||||
update={setPassword}
|
|
||||||
placeholder={t('passwordPlaceholder')}
|
|
||||||
valid={() => true}
|
|
||||||
/>
|
|
||||||
</Bullet>
|
|
||||||
<Bullet num="2">
|
|
||||||
<h5>{t('confirmWithMfa')}</h5>
|
|
||||||
<CodeInput code={code} setCode={setCode} t={t} />
|
|
||||||
</Bullet>
|
|
||||||
<button
|
|
||||||
className="btn btn-error btn-lg block w-full"
|
|
||||||
onClick={disableMfa}
|
|
||||||
disabled={code.length < 4 || password.length < 3}
|
|
||||||
>
|
|
||||||
{t('disableMfa')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{scratchCodes ? (
|
|
||||||
<>
|
|
||||||
<h3>{t('account:mfaScratchCodes')}</h3>
|
|
||||||
<p>{t('account:mfaScratchCodesMsg1')}</p>
|
|
||||||
<p>{t('account:mfaScratchCodesMsg2')}</p>
|
|
||||||
<div className="hljs my-4">
|
|
||||||
<div className=" flex flex-row justify-between items-center text-xs font-medium text-warning mt-1 border-b border-neutral-content border-opacity-25 px-4 py-1 mb-2 lg:text-sm">
|
|
||||||
<span>{t('account:mfaScratchCodes')}</span>
|
|
||||||
<CopyToClipboard
|
|
||||||
content={
|
|
||||||
'FreeSewing ' +
|
|
||||||
t('account:mfaScratchCodes') +
|
|
||||||
':\n' +
|
|
||||||
scratchCodes.map((code) => code + '\n').join('')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<pre className="language-shell hljs text-base lg:text-lg whitespace-break-spaces overflow-scroll pr-4">
|
|
||||||
{scratchCodes.map((code) => code + '\n')}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-row items-center mt-4">
|
|
||||||
{account.mfaEnabled ? (
|
|
||||||
disable ? null : (
|
|
||||||
<button className="btn btn-primary block w-full" onClick={() => setDisable(true)}>
|
|
||||||
{t('disableMfa')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
) : enable ? null : (
|
|
||||||
<div>
|
|
||||||
<button className="btn btn-primary block w-full" onClick={enableMfa}>
|
|
||||||
{t('mfaSetup')}
|
|
||||||
</button>
|
|
||||||
<Popout tip>
|
|
||||||
<h5>{t('mfaTipTitle')}</h5>
|
|
||||||
<p>{t('mfaTipMsg')}</p>
|
|
||||||
</Popout>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!welcome && <BackToAccountButton />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton, Icons, welcomeSteps } from './shared.mjs'
|
|
||||||
import { ContinueButton } from 'shared/components/buttons/continue-button.mjs'
|
|
||||||
import { ListInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
import { OkIcon, NoIcon } from 'shared/components/icons.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { PageLink } from 'shared/components/link.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status', 'newsletter']
|
|
||||||
|
|
||||||
export const NewsletterSettings = ({ welcome = false, bare = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
// State
|
|
||||||
const [selection, setSelection] = useState(account?.newsletter ? 'yes' : 'no')
|
|
||||||
|
|
||||||
// Helper method to update account
|
|
||||||
const update = async (val) => {
|
|
||||||
if (val !== selection) {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ newsletter: val === 'yes' ? true : false })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setSelection(val)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next step for onboarding
|
|
||||||
const nextHref =
|
|
||||||
welcomeSteps[account?.control].length > 2
|
|
||||||
? '/welcome/' + welcomeSteps[account?.control][2]
|
|
||||||
: '/docs/about/guide'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<ListInput
|
|
||||||
id="account-newsletter"
|
|
||||||
label={t('newsletterTitle')}
|
|
||||||
list={['yes', 'no'].map((val) => ({
|
|
||||||
val,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center w-full justify-between">
|
|
||||||
<span>{t(val === 'yes' ? 'newsletterYes' : 'noThanks')}</span>
|
|
||||||
{val === 'yes' ? (
|
|
||||||
<OkIcon className="w-8 h-8 text-success" stroke={4} />
|
|
||||||
) : (
|
|
||||||
<NoIcon className="w-8 h-8 text-error" stroke={3} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
desc: t(val === 'yes' ? 'newsletterYesd' : 'newsletterNod'),
|
|
||||||
}))}
|
|
||||||
current={selection}
|
|
||||||
update={update}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`account/site/account/newsletter`} />}
|
|
||||||
/>
|
|
||||||
{welcome ? (
|
|
||||||
<>
|
|
||||||
<ContinueButton btnProps={{ href: nextHref }} link />
|
|
||||||
{welcomeSteps[account?.control].length > 0 ? (
|
|
||||||
<>
|
|
||||||
<progress
|
|
||||||
className="progress progress-primary w-full mt-12"
|
|
||||||
value={200 / welcomeSteps[account?.control].length}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<span className="pt-4 text-sm font-bold opacity-50">
|
|
||||||
2 / {welcomeSteps[account?.control].length}
|
|
||||||
</span>
|
|
||||||
<Icons
|
|
||||||
done={welcomeSteps[account?.control].slice(0, 1)}
|
|
||||||
todo={welcomeSteps[account?.control].slice(2)}
|
|
||||||
current="newsletter"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : bare ? null : (
|
|
||||||
<BackToAccountButton />
|
|
||||||
)}
|
|
||||||
<Popout tip>
|
|
||||||
<p>{t('newsletter:subscribePs')}</p>
|
|
||||||
<p>
|
|
||||||
<PageLink
|
|
||||||
href={`/newsletter/unsubscribe?x=${account?.ehash}`}
|
|
||||||
txt={t('newsletter:unsubscribeLink')}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</Popout>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NewsletterSettings
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { AccountLinks } from './links.mjs'
|
|
||||||
|
|
||||||
export const AccountOverview = ({ app }) => <AccountLinks app={app} />
|
|
|
@ -1,66 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import { RightIcon } from 'shared/components/icons.mjs'
|
|
||||||
import { PasswordInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const PasswordSettings = ({ welcome = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
|
|
||||||
// Helper method to save password to account
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ password })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<PasswordInput
|
|
||||||
id="account-password"
|
|
||||||
label={t('passwordTitle')}
|
|
||||||
current={password}
|
|
||||||
update={setPassword}
|
|
||||||
valid={(val) => val.length > 0}
|
|
||||||
placeholder={t('passwordTitle')}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/password`} />}
|
|
||||||
/>
|
|
||||||
<SaveSettingsButton btnProps={{ onClick: save, disabled: password.length < 4 }} />
|
|
||||||
{!welcome && <BackToAccountButton />}
|
|
||||||
{!account.mfaEnabled && (
|
|
||||||
<Popout tip>
|
|
||||||
<h5>{t('mfaTipTitle')}</h5>
|
|
||||||
<p>{t('mfaTipMsg')}</p>
|
|
||||||
<p className="text-right m-0 pt-0">
|
|
||||||
<Link className="btn btn-secondary btn-accent" href="/account/mfa">
|
|
||||||
{t('mfa')} <RightIcon className="h-6 w-6 ml-2" />
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Popout>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,725 +0,0 @@
|
||||||
// Dependencies
|
|
||||||
import { useState, useEffect, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import {
|
|
||||||
capitalize,
|
|
||||||
shortDate,
|
|
||||||
cloudflareImageUrl,
|
|
||||||
horFlexClasses,
|
|
||||||
newPatternUrl,
|
|
||||||
} from 'shared/utils.mjs'
|
|
||||||
import orderBy from 'lodash.orderby'
|
|
||||||
import { freeSewingConfig as conf, controlLevels } from 'shared/config/freesewing.config.mjs'
|
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
// Context
|
|
||||||
import { ModalContext } from 'shared/context/modal-context.mjs'
|
|
||||||
// Components
|
|
||||||
import { PageLink, Link, AnchorLink } from 'shared/components/link.mjs'
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
import {
|
|
||||||
StringInput,
|
|
||||||
MarkdownInput,
|
|
||||||
PassiveImageInput,
|
|
||||||
ListInput,
|
|
||||||
} from 'shared/components/inputs.mjs'
|
|
||||||
import {
|
|
||||||
OkIcon,
|
|
||||||
NoIcon,
|
|
||||||
TrashIcon,
|
|
||||||
PlusIcon,
|
|
||||||
CameraIcon,
|
|
||||||
EditIcon,
|
|
||||||
ResetIcon,
|
|
||||||
RightIcon,
|
|
||||||
UploadIcon,
|
|
||||||
FreeSewingIcon,
|
|
||||||
CloneIcon,
|
|
||||||
BoolYesIcon,
|
|
||||||
BoolNoIcon,
|
|
||||||
LockIcon,
|
|
||||||
PatternIcon,
|
|
||||||
BookmarkIcon,
|
|
||||||
} from 'shared/components/icons.mjs'
|
|
||||||
import { DisplayRow } from './shared.mjs'
|
|
||||||
import { ModalWrapper } from 'shared/components/wrappers/modal.mjs'
|
|
||||||
import { Mdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
import Timeago from 'react-timeago'
|
|
||||||
import { TableWrapper } from 'shared/components/wrappers/table.mjs'
|
|
||||||
import { PatternReactPreview } from 'shared/components/pattern/preview.mjs'
|
|
||||||
import { Lightbox } from 'shared/components/lightbox.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'patterns', 'status']
|
|
||||||
|
|
||||||
export const ShowPattern = ({ id }) => {
|
|
||||||
// Hooks
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const { account } = useAccount()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [pattern, setPattern] = useState()
|
|
||||||
const [isOwn, setIsOwn] = useState(false)
|
|
||||||
|
|
||||||
// Effect
|
|
||||||
useEffect(() => {
|
|
||||||
const getPattern = async () => {
|
|
||||||
setLoadingStatus([true, t('backendLoadingStarted')])
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = await backend.getPattern(id)
|
|
||||||
if (result.success) {
|
|
||||||
setPattern(result.data.pattern)
|
|
||||||
if (result.data.pattern.userId === account.userId) setIsOwn(true)
|
|
||||||
setLoadingStatus([true, 'backendLoadingCompleted', true, true])
|
|
||||||
} else {
|
|
||||||
result = await backend.getPublicPattern(id)
|
|
||||||
if (result.success) {
|
|
||||||
setPattern({ ...result.data, public: true })
|
|
||||||
setLoadingStatus([true, 'backendLoadingCompleted', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (id) getPattern()
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
const bookmarkPattern = async () => {
|
|
||||||
setLoadingStatus([true, 'creatingBookmark'])
|
|
||||||
const result = await backend.createBookmark({
|
|
||||||
type: 'pattern',
|
|
||||||
title: pattern.name,
|
|
||||||
url: `/patterns?id=${pattern.id}`,
|
|
||||||
})
|
|
||||||
if (result.success) {
|
|
||||||
const id = result.data.bookmark.id
|
|
||||||
setLoadingStatus([
|
|
||||||
true,
|
|
||||||
<>
|
|
||||||
{t('status:bookmarkCreated')} <small>[#{id}]</small>
|
|
||||||
</>,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pattern) return <p>loading</p>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-row flex-wrap gap-4 max-w-7xl w-full">
|
|
||||||
<div className="max-w-lg grow w-full">
|
|
||||||
<Lightbox buttonClasses="w-full" boxClasses="" modalProps={{ fullWidth: 1 }}>
|
|
||||||
<PatternReactPreview {...pattern} />
|
|
||||||
</Lightbox>
|
|
||||||
</div>
|
|
||||||
<div className="w-full md:max-w-lg">
|
|
||||||
<DisplayRow title={t('name')}>{pattern.name}</DisplayRow>
|
|
||||||
<DisplayRow title="#">{pattern.id}</DisplayRow>
|
|
||||||
<DisplayRow title={t('account:publicView')}>
|
|
||||||
<PageLink href={`/pattern?id=${pattern.id}`} txt={`/pattern?id=${pattern.id}`} />
|
|
||||||
</DisplayRow>
|
|
||||||
<DisplayRow title={t('account:privateView')}>
|
|
||||||
<PageLink
|
|
||||||
href={`/account/pattern?id=${pattern.id}`}
|
|
||||||
txt={`/account/pattern?id=${pattern.id}`}
|
|
||||||
/>
|
|
||||||
</DisplayRow>
|
|
||||||
<DisplayRow title={t('created')}>
|
|
||||||
<Timeago date={pattern.createdAt} />
|
|
||||||
<span className="px-2 opacity-50">|</span>
|
|
||||||
{shortDate(i18n.language, pattern.createdAt, false)}
|
|
||||||
</DisplayRow>
|
|
||||||
<DisplayRow title={t('updated')}>
|
|
||||||
<Timeago date={pattern.createdAt} />
|
|
||||||
<span className="px-2 opacity-50">|</span>
|
|
||||||
{shortDate(i18n.language, pattern.updatedAt, false)}
|
|
||||||
</DisplayRow>
|
|
||||||
<DisplayRow title={t('public')}>
|
|
||||||
{pattern.public ? <BoolYesIcon /> : <BoolNoIcon />}
|
|
||||||
</DisplayRow>
|
|
||||||
<DisplayRow title={t('img')}>
|
|
||||||
<Lightbox buttonClasses="mask mask-squircle w-36 h-35">
|
|
||||||
<img src={cloudflareImageUrl({ id: pattern.img, variant: 'sq500' })} />
|
|
||||||
</Lightbox>
|
|
||||||
</DisplayRow>
|
|
||||||
{account.id ? (
|
|
||||||
<button className="btn btn-primary btn-outline mb-2 w-full" onClick={bookmarkPattern}>
|
|
||||||
<div className="flex flex-row items-center justify-between w-full">
|
|
||||||
<BookmarkIcon /> {t('bookmark')}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<Link
|
|
||||||
href={newPatternUrl({ design: pattern.design, settings: pattern.settings })}
|
|
||||||
className={`btn btn-primary ${horFlexClasses}`}
|
|
||||||
>
|
|
||||||
<CloneIcon /> {t('clonePattern')}
|
|
||||||
</Link>
|
|
||||||
{isOwn ? (
|
|
||||||
<>
|
|
||||||
<Popout tip noP>
|
|
||||||
<p>{t('account:ownPublicPattern')}</p>
|
|
||||||
<Link
|
|
||||||
href={`/account/pattern?id=${pattern.id}/`}
|
|
||||||
className={`btn btn-secondary ${horFlexClasses} mt-2`}
|
|
||||||
>
|
|
||||||
<LockIcon /> {t('account:privateView')}
|
|
||||||
</Link>
|
|
||||||
</Popout>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2>{t('account:notes')}</h2>
|
|
||||||
{isOwn ? 'is own' : 'is not own'}
|
|
||||||
<Mdx md={pattern.notes} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Pattern = ({ id }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, control } = useAccount()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
|
|
||||||
// Context
|
|
||||||
const { setModal } = useContext(ModalContext)
|
|
||||||
|
|
||||||
const [edit, setEdit] = useState(false)
|
|
||||||
const [pattern, setPattern] = useState()
|
|
||||||
// Set fields for editing
|
|
||||||
const [name, setName] = useState(pattern?.name)
|
|
||||||
const [image, setImage] = useState(pattern?.image)
|
|
||||||
const [isPublic, setIsPublic] = useState(pattern?.public ? true : false)
|
|
||||||
const [notes, setNotes] = useState(pattern?.notes || '')
|
|
||||||
|
|
||||||
// Effect
|
|
||||||
useEffect(() => {
|
|
||||||
const getPattern = async () => {
|
|
||||||
setLoadingStatus([true, t('backendLoadingStarted')])
|
|
||||||
const result = await backend.getPattern(id)
|
|
||||||
if (result.success) {
|
|
||||||
setPattern(result.data.pattern)
|
|
||||||
setName(result.data.pattern.name)
|
|
||||||
setImage(result.data.pattern.image)
|
|
||||||
setIsPublic(result.data.pattern.public ? true : false)
|
|
||||||
setNotes(result.data.pattern.notes)
|
|
||||||
setLoadingStatus([true, 'backendLoadingCompleted', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
if (id) getPattern()
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'gatheringInfo'])
|
|
||||||
// Compile data
|
|
||||||
const data = {}
|
|
||||||
if (name || name !== pattern.name) data.name = name
|
|
||||||
if (image || image !== pattern.image) data.img = image
|
|
||||||
if (notes || notes !== pattern.notes) data.notes = notes
|
|
||||||
if ([true, false].includes(isPublic) && isPublic !== pattern.public) data.public = isPublic
|
|
||||||
setLoadingStatus([true, 'savingPattern'])
|
|
||||||
const result = await backend.updatePattern(pattern.id, data)
|
|
||||||
if (result.success) {
|
|
||||||
setPattern(result.data.pattern)
|
|
||||||
setEdit(false)
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pattern) return null
|
|
||||||
|
|
||||||
const heading = (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap md:flex-nowrap flex-row gap-2 w-full">
|
|
||||||
<div className="w-full md:w-96 shrink-0">
|
|
||||||
<PatternCard pattern={pattern} size="md" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-end gap-2 mb-2 grow">
|
|
||||||
{account.control > 3 && pattern?.public ? (
|
|
||||||
<div className="flex flex-row gap-2 items-center">
|
|
||||||
<a
|
|
||||||
className="badge badge-secondary font-bold badge-lg"
|
|
||||||
href={`${conf.backend}/patterns/${pattern.id}.json`}
|
|
||||||
>
|
|
||||||
JSON
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="badge badge-success font-bold badge-lg"
|
|
||||||
href={`${conf.backend}/patterns/${pattern.id}.yaml`}
|
|
||||||
>
|
|
||||||
YAML
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span></span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setModal(
|
|
||||||
<ModalWrapper flex="col" justify="top lg:justify-center" slideFrom="right">
|
|
||||||
<img src={cloudflareImageUrl({ type: 'public', id: pattern.img })} />
|
|
||||||
</ModalWrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`btn btn-secondary btn-outline ${horFlexClasses}`}
|
|
||||||
>
|
|
||||||
<CameraIcon />
|
|
||||||
{t('showImage')}
|
|
||||||
</button>
|
|
||||||
{pattern.userId === account.id && (
|
|
||||||
<>
|
|
||||||
{edit ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setEdit(false)}
|
|
||||||
className={`btn btn-primary btn-outline ${horFlexClasses}`}
|
|
||||||
>
|
|
||||||
<ResetIcon />
|
|
||||||
{t('cancel')}
|
|
||||||
</button>
|
|
||||||
<button onClick={save} className={`btn btn-primary ${horFlexClasses}`}>
|
|
||||||
<UploadIcon />
|
|
||||||
{t('saveThing', { thing: t('account:pattern') })}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href={`/account/patterns/${pattern.design}/edit?id=${pattern.id}`}
|
|
||||||
className={`btn btn-primary btn-outline ${horFlexClasses}`}
|
|
||||||
>
|
|
||||||
<FreeSewingIcon /> {t('updatePattern')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={newPatternUrl({ design: pattern.design, settings: pattern.settings })}
|
|
||||||
className={`btn btn-primary btn-outline ${horFlexClasses}`}
|
|
||||||
>
|
|
||||||
<CloneIcon /> {t('clonePattern')}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => setEdit(true)}
|
|
||||||
className={`btn btn-primary ${horFlexClasses}`}
|
|
||||||
>
|
|
||||||
<EditIcon /> {t('editThing', { thing: t('account:patternMetadata') })}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row flex-wrap gap-4 text-sm items-center justify-between mb-2"></div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!edit)
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
{heading}
|
|
||||||
<DisplayRow title={t('name')}>{pattern.name}</DisplayRow>
|
|
||||||
{control >= controlLevels.sets.notes && (
|
|
||||||
<DisplayRow title={t('notes')}>
|
|
||||||
<Mdx md={pattern.notes} />
|
|
||||||
</DisplayRow>
|
|
||||||
)}
|
|
||||||
{control >= controlLevels.patterns.public && (
|
|
||||||
<>
|
|
||||||
<DisplayRow title={t('public')}>
|
|
||||||
{pattern.public ? <BoolYesIcon /> : <BoolNoIcon />}
|
|
||||||
</DisplayRow>
|
|
||||||
{pattern.public && (
|
|
||||||
<DisplayRow title={t('permalink')}>
|
|
||||||
<PageLink href={`/pattern?id=${pattern.id}`} txt={`/patternid=?${pattern.id}`} />
|
|
||||||
</DisplayRow>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{control >= controlLevels.sets.createdAt && (
|
|
||||||
<DisplayRow title={t('created')}>
|
|
||||||
<Timeago date={pattern.createdAt} />
|
|
||||||
<span className="px-2 opacity-50">|</span>
|
|
||||||
{shortDate(i18n.language, pattern.createdAt, false)}
|
|
||||||
</DisplayRow>
|
|
||||||
)}
|
|
||||||
{control >= controlLevels.patterns.updatedAt && (
|
|
||||||
<DisplayRow title={t('updated')}>
|
|
||||||
<Timeago date={pattern.updatedAt} />
|
|
||||||
<span className="px-2 opacity-50">|</span>
|
|
||||||
{shortDate(i18n.language, pattern.updatedAt, false)}
|
|
||||||
</DisplayRow>
|
|
||||||
)}
|
|
||||||
{control >= controlLevels.patterns.id && (
|
|
||||||
<DisplayRow title={t('id')}>{pattern.id}</DisplayRow>
|
|
||||||
)}
|
|
||||||
<Popout tip noP>
|
|
||||||
<p>{t('account:ownPrivatePattern')}</p>
|
|
||||||
<Link
|
|
||||||
className={`btn btn-secondary ${horFlexClasses}`}
|
|
||||||
href={`/pattern?id=${pattern.id}`}
|
|
||||||
>
|
|
||||||
<PatternIcon />
|
|
||||||
{t('account:publicView')}
|
|
||||||
</Link>
|
|
||||||
</Popout>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
{heading}
|
|
||||||
<ul className="list list-disc list-inside ml-4">
|
|
||||||
<li>
|
|
||||||
<AnchorLink id="name" txt={t('name')} />
|
|
||||||
</li>
|
|
||||||
{account.control >= conf.account.sets.img ? (
|
|
||||||
<li>
|
|
||||||
<AnchorLink id="image" txt={t('image')} />
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
{['public', 'units', 'notes'].map((id) =>
|
|
||||||
account.control >= conf.account.sets[id] ? (
|
|
||||||
<li key={id}>
|
|
||||||
<AnchorLink id="units" txt={t(id)} />
|
|
||||||
</li>
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Name is always shown */}
|
|
||||||
<span id="name"></span>
|
|
||||||
<StringInput
|
|
||||||
id="pattern-name"
|
|
||||||
label={t('name')}
|
|
||||||
update={setName}
|
|
||||||
current={name}
|
|
||||||
original={pattern.name}
|
|
||||||
placeholder="Maurits Cornelis Escher"
|
|
||||||
valid={(val) => val && val.length > 0}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug="docs/about/site/patterns/name" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* img: Control level determines whether or not to show this */}
|
|
||||||
<span id="image"></span>
|
|
||||||
{account.control >= conf.account.sets.img ? (
|
|
||||||
<PassiveImageInput
|
|
||||||
id="pattern-img"
|
|
||||||
label={t('image')}
|
|
||||||
update={setImage}
|
|
||||||
current={image}
|
|
||||||
valid={(val) => val.length > 0}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug="docs/about/site/patterns/image" />}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* public: Control level determines whether or not to show this */}
|
|
||||||
<span id="public"></span>
|
|
||||||
{account.control >= conf.account.patterns.public ? (
|
|
||||||
<ListInput
|
|
||||||
id="pattern-public"
|
|
||||||
label={t('public')}
|
|
||||||
update={setIsPublic}
|
|
||||||
list={[
|
|
||||||
{
|
|
||||||
val: true,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center flex-wrap justify-between w-full">
|
|
||||||
<span>{t('publicPattern')}</span>
|
|
||||||
<OkIcon
|
|
||||||
className="w-8 h-8 text-success bg-base-100 rounded-full p-1"
|
|
||||||
stroke={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
desc: t('publicPatternDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: false,
|
|
||||||
label: (
|
|
||||||
<div className="flex flex-row items-center flex-wrap justify-between w-full">
|
|
||||||
<span>{t('privatePattern')}</span>
|
|
||||||
<NoIcon className="w-8 h-8 text-error bg-base-100 rounded-full p-1" stroke={3} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
desc: t('privatePatternDesc'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
current={isPublic}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug="docs/about/site/patterns/public" />}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* notes: Control level determines whether or not to show this */}
|
|
||||||
<span id="notes"></span>
|
|
||||||
{account.control >= conf.account.patterns.notes ? (
|
|
||||||
<MarkdownInput
|
|
||||||
id="pattern-notes"
|
|
||||||
label={t('notes')}
|
|
||||||
update={setNotes}
|
|
||||||
current={notes}
|
|
||||||
placeholder={t('mdSupport')}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug="docs/about/site/patterns/notes" />}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
onClick={save}
|
|
||||||
className="btn btn-primary btn-lg flex flex-row items-center gap-4 mx-auto mt-8"
|
|
||||||
>
|
|
||||||
<UploadIcon />
|
|
||||||
{t('saveThing', { thing: t('account:pattern') })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PatternCard = ({
|
|
||||||
pattern,
|
|
||||||
href = false,
|
|
||||||
onClick = false,
|
|
||||||
useA = false,
|
|
||||||
size = 'md',
|
|
||||||
}) => {
|
|
||||||
const sizes = {
|
|
||||||
lg: 96,
|
|
||||||
md: 52,
|
|
||||||
sm: 36,
|
|
||||||
xs: 20,
|
|
||||||
}
|
|
||||||
const s = sizes[size]
|
|
||||||
|
|
||||||
const wrapperProps = {
|
|
||||||
className: `bg-base-300 w-full mb-2 mx-auto flex flex-col items-start text-center justify-center rounded shadow py-4 h-${s} w-${s}`,
|
|
||||||
style: {
|
|
||||||
backgroundImage: `url(${cloudflareImageUrl({ type: 'w1000', id: pattern.img })})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundPosition: '50%',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (pattern.img === 'default-avatar') wrapperProps.style.backgroundPosition = 'bottom right'
|
|
||||||
|
|
||||||
const inner = null
|
|
||||||
|
|
||||||
// Is it a button with an onClick handler?
|
|
||||||
if (onClick)
|
|
||||||
return (
|
|
||||||
<button {...wrapperProps} onClick={onClick}>
|
|
||||||
{inner}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Returns a link to an internal page
|
|
||||||
if (href && !useA)
|
|
||||||
return (
|
|
||||||
<Link {...wrapperProps} href={href}>
|
|
||||||
{inner}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Returns a link to an external page
|
|
||||||
if (href && useA)
|
|
||||||
return (
|
|
||||||
<a {...wrapperProps} href={href}>
|
|
||||||
{inner}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Returns a div
|
|
||||||
return <div {...wrapperProps}>{inner}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component to show the sort header in the pattern table
|
|
||||||
const SortButton = ({ field, label, order, orderAsc, updateOrder }) => (
|
|
||||||
<button
|
|
||||||
onClick={() => updateOrder(field)}
|
|
||||||
className="btn-link text-secondary flex flex-row gap-2 items-center decoration-0 no-underline"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{order === field ? (
|
|
||||||
<RightIcon className={`w-5 h-5 ${orderAsc ? '-rotate-90' : 'rotate-90'}`} stroke={3} />
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Component for the account/patterns page
|
|
||||||
export const Patterns = () => {
|
|
||||||
const router = useRouter()
|
|
||||||
const { locale } = router
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus, LoadingProgress } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [patterns, setPatterns] = useState([])
|
|
||||||
const [selected, setSelected] = useState({})
|
|
||||||
const [refresh, setRefresh] = useState(0)
|
|
||||||
const [order, setOrder] = useState('id')
|
|
||||||
const [orderAsc, setOrderAsc] = useState(true)
|
|
||||||
|
|
||||||
// Helper var to see how many are selected
|
|
||||||
const selCount = Object.keys(selected).length
|
|
||||||
|
|
||||||
// Effects
|
|
||||||
useEffect(() => {
|
|
||||||
const getPatterns = async () => {
|
|
||||||
const result = await backend.getPatterns()
|
|
||||||
if (result.success) setPatterns(result.data.patterns)
|
|
||||||
}
|
|
||||||
getPatterns()
|
|
||||||
}, [refresh])
|
|
||||||
|
|
||||||
// Helper method to toggle single selection
|
|
||||||
const toggleSelect = (id) => {
|
|
||||||
const newSelected = { ...selected }
|
|
||||||
if (newSelected[id]) delete newSelected[id]
|
|
||||||
else newSelected[id] = 1
|
|
||||||
setSelected(newSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to toggle select all
|
|
||||||
const toggleSelectAll = () => {
|
|
||||||
if (selCount === patterns.length) setSelected({})
|
|
||||||
else {
|
|
||||||
const newSelected = {}
|
|
||||||
for (const pattern of patterns) newSelected[pattern.id] = 1
|
|
||||||
setSelected(newSelected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to delete one or more patterns
|
|
||||||
const removeSelectedPatterns = async () => {
|
|
||||||
let i = 0
|
|
||||||
for (const pattern in selected) {
|
|
||||||
i++
|
|
||||||
await backend.removePattern(pattern)
|
|
||||||
setLoadingStatus([
|
|
||||||
true,
|
|
||||||
<LoadingProgress val={i} max={selCount} msg={t('removingPatterns')} key="linter" />,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
setSelected({})
|
|
||||||
setRefresh(refresh + 1)
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to update the order state
|
|
||||||
const updateOrder = (field) => {
|
|
||||||
if (order !== field) {
|
|
||||||
setOrder(field)
|
|
||||||
setOrderAsc(true)
|
|
||||||
} else setOrderAsc(!orderAsc)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl xl:pl-4">
|
|
||||||
<p className="text-center md:text-right">
|
|
||||||
<Link className="btn btn-primary capitalize w-full md:w-auto" href="/new/pattern">
|
|
||||||
<PlusIcon />
|
|
||||||
{t('patternNew')}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<button className="btn btn-error" onClick={removeSelectedPatterns} disabled={selCount < 1}>
|
|
||||||
<TrashIcon /> {selCount} {t('patterns')}
|
|
||||||
</button>
|
|
||||||
<TableWrapper>
|
|
||||||
<table className="table table-auto">
|
|
||||||
<thead className="border border-base-300 border-b-2 border-t-0 border-x-0">
|
|
||||||
<tr className="">
|
|
||||||
<th className="">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-secondary"
|
|
||||||
onClick={toggleSelectAll}
|
|
||||||
checked={patterns.length === selCount}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<SortButton field="id" label="#" {...{ order, orderAsc, updateOrder }} />
|
|
||||||
</th>
|
|
||||||
<th>{t('account:img')}</th>
|
|
||||||
<th>
|
|
||||||
<SortButton
|
|
||||||
field="name"
|
|
||||||
label={t('account:name')}
|
|
||||||
{...{ order, orderAsc, updateOrder }}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<SortButton
|
|
||||||
field="design"
|
|
||||||
label={t('account:design')}
|
|
||||||
{...{ order, orderAsc, updateOrder }}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<SortButton
|
|
||||||
field="createdAt"
|
|
||||||
label={t('account:createdAt')}
|
|
||||||
{...{ order, orderAsc, updateOrder }}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<SortButton
|
|
||||||
field="public"
|
|
||||||
label={t('account:public')}
|
|
||||||
{...{ order, orderAsc, updateOrder }}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{orderBy(patterns, order, orderAsc ? 'asc' : 'desc').map((pattern, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected[pattern.id] ? true : false}
|
|
||||||
className="checkbox checkbox-secondary"
|
|
||||||
onClick={() => toggleSelect(pattern.id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">{pattern.id}</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
<PatternCard
|
|
||||||
href={`/account/pattern?id=${pattern.id}`}
|
|
||||||
pattern={pattern}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
<PageLink href={`/account/pattern?id=${pattern.id}`} txt={pattern.name} />
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
<PageLink href={`/designs/${pattern.design}`} txt={capitalize(pattern.design)} />
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
{shortDate(locale, pattern.createdAt, false)}
|
|
||||||
</td>
|
|
||||||
<td className="text-base font-medium">
|
|
||||||
{pattern.public ? <BoolYesIcon /> : <BoolNoIcon />}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</TableWrapper>
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { SaveSettingsButton } from 'shared/components/buttons/save-settings-button.mjs'
|
|
||||||
import { StringInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const PlatformSettings = ({ platform }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [platformId, setPlatformId] = useState(account.data[platform] || '')
|
|
||||||
|
|
||||||
// Helper method to save changes
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const data = { data: {} }
|
|
||||||
data.data[platform] = platformId
|
|
||||||
const result = await backend.updateAccount(data)
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<StringInput
|
|
||||||
id={`account-${platform}`}
|
|
||||||
label={t(platform === 'website' ? 'account:websiteTitle' : 'account:platformTitle', {
|
|
||||||
platform: platform,
|
|
||||||
})}
|
|
||||||
current={platformId}
|
|
||||||
update={setPlatformId}
|
|
||||||
valid={(val) => val.length > 0}
|
|
||||||
placeholder={'joostdecock'}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/platform`} />}
|
|
||||||
/>
|
|
||||||
<SaveSettingsButton btnProps={{ onClick: save }} />
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useContext } from 'react'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const ReloadAccount = ({ title = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// Helper method to reload account
|
|
||||||
const reload = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.reloadAccount()
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
{title ? <h2>{t('reloadMsg1')}</h2> : null}
|
|
||||||
<p>{t('reloadMsg2')}</p>
|
|
||||||
<button className="btn btn-primary capitalize w-full my-2" onClick={reload}>
|
|
||||||
{t('reload')}
|
|
||||||
</button>
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const RemoveAccount = () => {
|
|
||||||
// Hooks
|
|
||||||
const { signOut } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// Helper method to export account
|
|
||||||
const removeAccount = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.removeAccount()
|
|
||||||
if (result.success) {
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
signOut()
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<Popout warning>
|
|
||||||
<h3>{t('noWayBack')}</h3>
|
|
||||||
<button className="btn btn-error capitalize w-full my-2" onClick={removeAccount}>
|
|
||||||
{t('remove')}
|
|
||||||
</button>
|
|
||||||
</Popout>
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { BackToAccountButton } from './shared.mjs'
|
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const RestrictAccount = () => {
|
|
||||||
// Hooks
|
|
||||||
const { signOut } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { t } = useTranslation(ns)
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
|
|
||||||
// Helper method to export account
|
|
||||||
const restrictAccount = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.restrictAccount()
|
|
||||||
if (result.success) {
|
|
||||||
setLoadingStatus([true, 'nailedIt', true, true])
|
|
||||||
signOut()
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<Popout warning>
|
|
||||||
<h5>{t('proceedWithCaution')}</h5>
|
|
||||||
<p className="text-lg">{t('restrictWarning')}</p>
|
|
||||||
<button className="btn btn-error capitalize w-full my-2" onClick={restrictAccount}>
|
|
||||||
{t('restrict')}
|
|
||||||
</button>
|
|
||||||
</Popout>
|
|
||||||
<BackToAccountButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
export const ns = ['roles']
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
user: 'primary',
|
|
||||||
curator: 'secondary',
|
|
||||||
bughunter: 'accent',
|
|
||||||
support: 'warning',
|
|
||||||
admin: 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AccountRole = ({ role }) => {
|
|
||||||
const color = colors[role]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={``}>
|
|
||||||
<span
|
|
||||||
className={`text-xs uppercase bg-${color} rounded-l-lg pl-1 font-bold text-base-100 border border-2 border-solid border-${color}`}
|
|
||||||
>
|
|
||||||
role
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-xs uppercase bg-base-100 text-${color} rounded-r-lg px-1 font-bold border border-2 border-solid border-${color}`}
|
|
||||||
>
|
|
||||||
{role}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -19,126 +19,3 @@ export const welcomeSteps = {
|
||||||
4: ['', 'newsletter', 'units', 'compare', 'username', 'bio', 'img'],
|
4: ['', 'newsletter', 'units', 'compare', 'username', 'bio', 'img'],
|
||||||
5: [''],
|
5: [''],
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
import { Spinner } from 'shared/components/spinner.mjs'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import {
|
|
||||||
CogIcon,
|
|
||||||
FingerprintIcon as ControlIcon,
|
|
||||||
NewsletterIcon,
|
|
||||||
UnitsIcon,
|
|
||||||
CompareIcon,
|
|
||||||
LabelIcon,
|
|
||||||
BioIcon,
|
|
||||||
UserIcon,
|
|
||||||
LeftIcon,
|
|
||||||
OkIcon,
|
|
||||||
NoIcon,
|
|
||||||
} from 'shared/components/icons.mjs'
|
|
||||||
|
|
||||||
const btnClasses = {
|
|
||||||
dflt:
|
|
||||||
'btn w-full mt-2 btn-secondary ' +
|
|
||||||
'flex flex-row flex-nowrap items-center gap-4 py-4 h-auto ' +
|
|
||||||
'border border-secondary justify-start text-left bg-opacity-30',
|
|
||||||
active:
|
|
||||||
'btn-ghost bg-secondary hover:bg-secondary ' + 'hover:bg-opacity-30 hover:border-secondary',
|
|
||||||
inactive:
|
|
||||||
'hover:bg-opacity-20 hover:bg-secondary btn-ghost ' +
|
|
||||||
'border border-secondary hover:border hover:border-secondary',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NumberBullet = ({ nr, color = 'secondary' }) => (
|
|
||||||
<span
|
|
||||||
className={`p-2 w-8 h-8 flex flex-col items-center justify-center shrink-0 rounded-full text-center p-0 py-2 bg-${color} text-${color}-content border-2 border-base-100`}
|
|
||||||
>
|
|
||||||
{nr}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const BackToAccountButton = ({ loading = false }) => {
|
|
||||||
const { t } = useTranslation(['account'])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link className={`btn ${loading ? 'btn-accent' : 'btn-secondary'} mt-4 pr-6`} href="/account">
|
|
||||||
<span className="flex flex-row items-center gap-2">
|
|
||||||
{loading ? <Spinner /> : <LeftIcon />}
|
|
||||||
{t('yourAccount')}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Choice = ({
|
|
||||||
val,
|
|
||||||
update,
|
|
||||||
current,
|
|
||||||
children,
|
|
||||||
bool = false,
|
|
||||||
boolChoices = {
|
|
||||||
yes: <OkIcon className="w-6 h-6 text-success shrink-0" stroke={4} />,
|
|
||||||
no: <NoIcon className="w-6 h-6 text-error shrink-0" stroke={3} />,
|
|
||||||
},
|
|
||||||
}) => {
|
|
||||||
const active = val === current
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`${btnClasses.dflt} ${active ? btnClasses.active : btnClasses.inactive}`}
|
|
||||||
onClick={() => update(val)}
|
|
||||||
>
|
|
||||||
{bool ? boolChoices[val] : <NumberBullet nr={val} />}
|
|
||||||
<div className={`normal-case text-base-content`}>{children}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DoneIcon = ({ href }) => (
|
|
||||||
<Link href={`/welcome/${href}`} className="text-success hover:text-secondary">
|
|
||||||
<TopicIcon href={href} />
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
export const TodoIcon = ({ href }) => (
|
|
||||||
<Link href={`/welcome/${href}`} className="text-secondary w-6 h-6 opacity-50 hover:opacity-100">
|
|
||||||
<TopicIcon href={href} />
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
const TopicIcon = (props) => {
|
|
||||||
const Icon =
|
|
||||||
props.href === '' || props.href === 'control'
|
|
||||||
? ControlIcon
|
|
||||||
: icons[props.href]
|
|
||||||
? icons[props.href]
|
|
||||||
: CogIcon
|
|
||||||
|
|
||||||
return <Icon {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const DoingIcon = ({ href }) => <TopicIcon href={href} className="w-6 h-6 text-base-content" />
|
|
||||||
|
|
||||||
export const Icons = ({ done = [], todo = [], current = '' }) => (
|
|
||||||
<div className="m-auto flex flex-row items-center justify-center gap-2">
|
|
||||||
{done.map((href) => (
|
|
||||||
<DoneIcon href={href} key={href} />
|
|
||||||
))}
|
|
||||||
<DoingIcon href={current} />
|
|
||||||
{todo.map((href) => (
|
|
||||||
<TodoIcon href={href} key={href} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
newsletter: NewsletterIcon,
|
|
||||||
units: UnitsIcon,
|
|
||||||
compare: CompareIcon,
|
|
||||||
username: LabelIcon,
|
|
||||||
bio: BioIcon,
|
|
||||||
img: UserIcon,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { freeSewingConfig } from 'shared/config/freesewing.config.mjs'
|
|
||||||
|
|
||||||
export const ns = ['status']
|
|
||||||
|
|
||||||
export const AccountStatus = ({ status }) => {
|
|
||||||
const { name, color } = freeSewingConfig.statuses[status]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={``}>
|
|
||||||
<span
|
|
||||||
className={`text-xs uppercase bg-${color} rounded-l-lg pl-1 font-bold text-base-100 border border-2 border-solid border-${color}`}
|
|
||||||
>
|
|
||||||
status
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-xs uppercase bg-base-100 text-${color} rounded-r-lg px-1 font-bold border border-2 border-solid border-${color}`}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,109 +0,0 @@
|
||||||
// Context
|
|
||||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useContext } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
// Components
|
|
||||||
import { Icons, welcomeSteps, BackToAccountButton } from './shared.mjs'
|
|
||||||
import { OkIcon, NoIcon } from 'shared/components/icons.mjs'
|
|
||||||
import { ContinueButton } from 'shared/components/buttons/continue-button.mjs'
|
|
||||||
import { StringInput } from 'shared/components/inputs.mjs'
|
|
||||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
|
||||||
|
|
||||||
export const ns = ['account', 'status']
|
|
||||||
|
|
||||||
export const UsernameSettings = ({ welcome = false }) => {
|
|
||||||
// Hooks
|
|
||||||
const { account, setAccount } = useAccount()
|
|
||||||
const backend = useBackend()
|
|
||||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
|
||||||
const { t, i18n } = useTranslation(ns)
|
|
||||||
const [username, setUsername] = useState(account.username)
|
|
||||||
const [available, setAvailable] = useState(true)
|
|
||||||
|
|
||||||
const update = async (value) => {
|
|
||||||
if (value !== username) {
|
|
||||||
setUsername(value)
|
|
||||||
const result = await backend.isUsernameAvailable(value)
|
|
||||||
if (result?.response?.response?.status === 404) setAvailable(true)
|
|
||||||
else setAvailable(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setLoadingStatus([true, 'processingUpdate'])
|
|
||||||
const result = await backend.updateAccount({ username })
|
|
||||||
if (result.success) {
|
|
||||||
setAccount(result.data.account)
|
|
||||||
setLoadingStatus([true, 'settingsSaved', true, true])
|
|
||||||
} else setLoadingStatus([true, 'backendError', true, true])
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextHref =
|
|
||||||
welcomeSteps[account.control].length > 5
|
|
||||||
? '/welcome/' + welcomeSteps[account.control][5]
|
|
||||||
: '/docs/about/guide'
|
|
||||||
|
|
||||||
let btnClasses = 'btn mt-4 capitalize '
|
|
||||||
if (welcome) btnClasses += 'w-64 btn-secondary'
|
|
||||||
else btnClasses += 'w-full btn-primary'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<StringInput
|
|
||||||
id="account-username"
|
|
||||||
label={t('usernameTitle')}
|
|
||||||
current={username}
|
|
||||||
update={update}
|
|
||||||
valid={() => available}
|
|
||||||
placeholder={'Sorcha Ni Dhubghaill'}
|
|
||||||
labelBL={
|
|
||||||
<span className="flex flex-row gap-1 items-center">
|
|
||||||
{available ? (
|
|
||||||
<>
|
|
||||||
<OkIcon className="w-4 h-4 text-success" stroke={4} /> {t('usernameAvailable')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<NoIcon className="w-4 h-4 text-error" stroke={3} /> {t('usernameNotAvailable')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
docs={<DynamicMdx language={i18n.language} slug={`docs/about/site/account/username`} />}
|
|
||||||
/>
|
|
||||||
<button className={btnClasses} disabled={!available} onClick={save}>
|
|
||||||
<span className="flex flex-row items-center gap-2">
|
|
||||||
{available ? t('save') : t('usernameNotAvailable')}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{welcome ? (
|
|
||||||
<>
|
|
||||||
<ContinueButton btnProps={{ href: nextHref }} link />
|
|
||||||
{welcomeSteps[account.control].length > 0 ? (
|
|
||||||
<>
|
|
||||||
<progress
|
|
||||||
className="progress progress-primary w-full mt-12"
|
|
||||||
value={500 / welcomeSteps[account.control].length}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<span className="pt-4 text-sm font-bold opacity-50">
|
|
||||||
5 / {welcomeSteps[account.control].length}
|
|
||||||
</span>
|
|
||||||
<Icons
|
|
||||||
done={welcomeSteps[account.control].slice(0, 4)}
|
|
||||||
todo={welcomeSteps[account.control].slice(5)}
|
|
||||||
current="username"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<BackToAccountButton />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
30
packages/react/components/Button/index.mjs
Normal file
30
packages/react/components/Button/index.mjs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button with an icon and a label. Common across our UI
|
||||||
|
*
|
||||||
|
* @param {object} props - All React props
|
||||||
|
* @param {string} title - The button title
|
||||||
|
* @param {string} className - Any EXTRA classes to add
|
||||||
|
* @param {string} color - The main button color
|
||||||
|
* @param {string} href - Set this to make it a link
|
||||||
|
*/
|
||||||
|
export const IconButton = ({
|
||||||
|
title = '',
|
||||||
|
className = '',
|
||||||
|
onClick = false,
|
||||||
|
href = false,
|
||||||
|
color = 'primary',
|
||||||
|
children = [],
|
||||||
|
btnProps = {},
|
||||||
|
}) => {
|
||||||
|
const allProps = {
|
||||||
|
className: `flex flex-row gap-2 lg:gap-12 items-center justify-between w-full lg:w-auto daisy-btn daisy-btn-${color} capitalize my-2 ${className}`,
|
||||||
|
title: title,
|
||||||
|
...btnProps,
|
||||||
|
}
|
||||||
|
if (onClick) allProps.onClick = onClick
|
||||||
|
else if (href) allProps.href = href
|
||||||
|
|
||||||
|
return onClick ? <button {...allProps}>{children}</button> : <a {...allProps}>{children}</a>
|
||||||
|
}
|
|
@ -29,6 +29,7 @@
|
||||||
"./xray": "./src/pattern-xray/index.mjs",
|
"./xray": "./src/pattern-xray/index.mjs",
|
||||||
"./components/Account": "./components/Account/index.mjs",
|
"./components/Account": "./components/Account/index.mjs",
|
||||||
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs",
|
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs",
|
||||||
|
"./components/Button": "./components/Button/index.mjs",
|
||||||
"./components/Control": "./components/Control/index.mjs",
|
"./components/Control": "./components/Control/index.mjs",
|
||||||
"./components/CopyToClipboard": "./components/CopyToClipboard/index.mjs",
|
"./components/CopyToClipboard": "./components/CopyToClipboard/index.mjs",
|
||||||
"./components/Docusaurus": "./components/Docusaurus/index.mjs",
|
"./components/Docusaurus": "./components/Docusaurus/index.mjs",
|
||||||
|
|
|
@ -5,11 +5,20 @@ sidebar_position: 43
|
||||||
|
|
||||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||||
import { RoleBlock } from '@freesewing/react/components/Role'
|
import { RoleBlock } from '@freesewing/react/components/Role'
|
||||||
import { Bio } from '@freesewing/react/components/Account'
|
import { ImportSet } from '@freesewing/react/components/Account'
|
||||||
import Link from '@docusaurus/Link'
|
|
||||||
|
|
||||||
<DocusaurusDoc>
|
<DocusaurusDoc>
|
||||||
<RoleBlock user>
|
<RoleBlock user>
|
||||||
<Bio Link={Link} />
|
|
||||||
|
This page allows you to import data into your FreeSewing account.
|
||||||
|
|
||||||
|
Currently, we support importing the following types of data:
|
||||||
|
|
||||||
|
- Measurements Sets
|
||||||
|
|
||||||
|
## Import Measurements Sets {#sets}
|
||||||
|
|
||||||
|
<ImportSet />
|
||||||
|
|
||||||
</RoleBlock>
|
</RoleBlock>
|
||||||
</DocusaurusDoc>
|
</DocusaurusDoc>
|
||||||
|
|
|
@ -5,11 +5,10 @@ sidebar_position: 43
|
||||||
|
|
||||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||||
import { RoleBlock } from '@freesewing/react/components/Role'
|
import { RoleBlock } from '@freesewing/react/components/Role'
|
||||||
import { Bio } from '@freesewing/react/components/Account'
|
import { Reload } from '@freesewing/react/components/Account'
|
||||||
import Link from '@docusaurus/Link'
|
|
||||||
|
|
||||||
<DocusaurusDoc>
|
<DocusaurusDoc>
|
||||||
<RoleBlock user>
|
<RoleBlock user>
|
||||||
<Bio Link={Link} />
|
<Reload />
|
||||||
</RoleBlock>
|
</RoleBlock>
|
||||||
</DocusaurusDoc>
|
</DocusaurusDoc>
|
||||||
|
|
|
@ -5,11 +5,11 @@ sidebar_position: 43
|
||||||
|
|
||||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||||
import { RoleBlock } from '@freesewing/react/components/Role'
|
import { RoleBlock } from '@freesewing/react/components/Role'
|
||||||
import { Bio } from '@freesewing/react/components/Account'
|
import { Remove } from '@freesewing/react/components/Account'
|
||||||
import Link from '@docusaurus/Link'
|
import Link from '@docusaurus/Link'
|
||||||
|
|
||||||
<DocusaurusDoc>
|
<DocusaurusDoc>
|
||||||
<RoleBlock user>
|
<RoleBlock user>
|
||||||
<Bio Link={Link} />
|
<Remove />
|
||||||
</RoleBlock>
|
</RoleBlock>
|
||||||
</DocusaurusDoc>
|
</DocusaurusDoc>
|
||||||
|
|
|
@ -5,11 +5,11 @@ sidebar_position: 43
|
||||||
|
|
||||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||||
import { RoleBlock } from '@freesewing/react/components/Role'
|
import { RoleBlock } from '@freesewing/react/components/Role'
|
||||||
import { Bio } from '@freesewing/react/components/Account'
|
import { Restrict } from '@freesewing/react/components/Account'
|
||||||
import Link from '@docusaurus/Link'
|
import Link from '@docusaurus/Link'
|
||||||
|
|
||||||
<DocusaurusDoc>
|
<DocusaurusDoc>
|
||||||
<RoleBlock user>
|
<RoleBlock user>
|
||||||
<Bio Link={Link} />
|
<Restrict Link={Link} />
|
||||||
</RoleBlock>
|
</RoleBlock>
|
||||||
</DocusaurusDoc>
|
</DocusaurusDoc>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue