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/Account": "./components/Account/index.mjs"
|
||||
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs"
|
||||
"./components/Button": "./components/Button/index.mjs"
|
||||
"./components/Control": "./components/Control/index.mjs"
|
||||
"./components/CopyToClipboard": "./components/CopyToClipboard/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 { Password } from './Password.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 {
|
||||
Bookmarks,
|
||||
|
@ -51,4 +56,9 @@ export {
|
|||
Consent,
|
||||
Password,
|
||||
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'],
|
||||
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",
|
||||
"./components/Account": "./components/Account/index.mjs",
|
||||
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs",
|
||||
"./components/Button": "./components/Button/index.mjs",
|
||||
"./components/Control": "./components/Control/index.mjs",
|
||||
"./components/CopyToClipboard": "./components/CopyToClipboard/index.mjs",
|
||||
"./components/Docusaurus": "./components/Docusaurus/index.mjs",
|
||||
|
|
|
@ -5,11 +5,20 @@ sidebar_position: 43
|
|||
|
||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||
import { RoleBlock } from '@freesewing/react/components/Role'
|
||||
import { Bio } from '@freesewing/react/components/Account'
|
||||
import Link from '@docusaurus/Link'
|
||||
import { ImportSet } from '@freesewing/react/components/Account'
|
||||
|
||||
<DocusaurusDoc>
|
||||
<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>
|
||||
</DocusaurusDoc>
|
||||
|
|
|
@ -5,11 +5,10 @@ sidebar_position: 43
|
|||
|
||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||
import { RoleBlock } from '@freesewing/react/components/Role'
|
||||
import { Bio } from '@freesewing/react/components/Account'
|
||||
import Link from '@docusaurus/Link'
|
||||
import { Reload } from '@freesewing/react/components/Account'
|
||||
|
||||
<DocusaurusDoc>
|
||||
<RoleBlock user>
|
||||
<Bio Link={Link} />
|
||||
<Reload />
|
||||
</RoleBlock>
|
||||
</DocusaurusDoc>
|
||||
|
|
|
@ -5,11 +5,11 @@ sidebar_position: 43
|
|||
|
||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||
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'
|
||||
|
||||
<DocusaurusDoc>
|
||||
<RoleBlock user>
|
||||
<Bio Link={Link} />
|
||||
<Remove />
|
||||
</RoleBlock>
|
||||
</DocusaurusDoc>
|
||||
|
|
|
@ -5,11 +5,11 @@ sidebar_position: 43
|
|||
|
||||
import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus'
|
||||
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'
|
||||
|
||||
<DocusaurusDoc>
|
||||
<RoleBlock user>
|
||||
<Bio Link={Link} />
|
||||
<Restrict Link={Link} />
|
||||
</RoleBlock>
|
||||
</DocusaurusDoc>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue