wip: Updates to packages
This commit is contained in:
parent
1c81f46dee
commit
755c7395ba
45 changed files with 5268 additions and 22 deletions
|
@ -435,20 +435,20 @@ sde:
|
|||
'daisyui': *daisyui
|
||||
'echarts': *echarts
|
||||
'echarts-for-react': *echartsReact
|
||||
'file-saver': *filesaver
|
||||
'file-saver': latest
|
||||
'i18next': *i18next
|
||||
'jotai': *jotai
|
||||
'jotai-location': *jotai-location
|
||||
'js-yaml': *jsyaml
|
||||
'lodash.debounce': *_debounce
|
||||
'lodash.debounce': latest
|
||||
'lodash.get': *_get
|
||||
'lodash.orderby': *_orderby
|
||||
'lodash.set': *_set
|
||||
'mustache': *mustache
|
||||
"next": *next
|
||||
'next-i18next': *nextI18next
|
||||
'pdfkit': *pdfkit
|
||||
'postcss-for': *postcssfor
|
||||
'pdfkit': latest
|
||||
'postcss-for': latest
|
||||
"react": *react
|
||||
"react-dom": *react
|
||||
'react-copy-to-clipboard': *reactCopyToClipboard
|
||||
|
@ -459,13 +459,13 @@ sde:
|
|||
'react-timeago': *reactTimeago
|
||||
'react-zoom-pan-pinch': *zoompanpinch
|
||||
'remark-gfm': *remarkGfm
|
||||
'remark-frontmatter': *remarkfrontmatter
|
||||
'remark-frontmatter': latest
|
||||
'remark-mdx-frontmatter': *mdxfrontmatter
|
||||
'remark-smartypants': *smartypants
|
||||
"slugify": *slugify
|
||||
'svg-to-pdfkit': *svgtopdfkit
|
||||
'remark-smartypants': latest
|
||||
"slugify": latest
|
||||
'svg-to-pdfkit': latest
|
||||
'tailwindcss': *tailwindcss
|
||||
'tlds': *rlds
|
||||
'tlds': latest
|
||||
'use-local-storage-state': *use-local-storage-state
|
||||
'web-worker': *webworker
|
||||
'web-worker': latest
|
||||
|
||||
|
|
|
@ -90,7 +90,9 @@ packageJson:
|
|||
"./pattern": "./src/pattern/index.mjs"
|
||||
"./xray": "./src/pattern-xray/index.mjs"
|
||||
# Components
|
||||
"./components/Account": "./components/Account/index.mjs"
|
||||
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs"
|
||||
"./components/Control": "./components/Control/index.mjs"
|
||||
"./components/DocusaurusPage": "./components/DocusaurusPage/index.mjs"
|
||||
"./components/Editor": "./components/Editor/index.mjs"
|
||||
"./components/Icon": "./components/Icon/index.mjs"
|
||||
|
@ -102,6 +104,7 @@ packageJson:
|
|||
"./components/Modal": "./components/Modal/index.mjs"
|
||||
"./components/Pattern": "./components/Pattern/index.mjs"
|
||||
"./components/Popout": "./components/Popout/index.mjs"
|
||||
"./components/Role": "./components/Role/index.mjs"
|
||||
"./components/SignIn": "./components/SignIn/index.mjs"
|
||||
"./components/Spinner": "./components/Spinner/index.mjs"
|
||||
"./components/Tab": "./components/Tab/index.mjs"
|
||||
|
|
6
packages/config/src/cloudflare.mjs
Normal file
6
packages/config/src/cloudflare.mjs
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const cloudflare = {
|
||||
account: 'edd96e8b19d1be5946c5f7983365bda4',
|
||||
url: 'https://imagedelivery.net/ouSuR9yY1bHt-fuAokSA5Q/',
|
||||
variants: ['public', 'sq100', 'sq200', 'sq500', 'w200', 'w500', 'w1000', 'w2000'],
|
||||
dflt: 'https://imagedelivery.net/ouSuR9yY1bHt-fuAokSA5Q/365cc64e-1502-4d2b-60e0-cc8beee73f00/public',
|
||||
}
|
|
@ -35,7 +35,6 @@ const account = {
|
|||
email: 3,
|
||||
},
|
||||
settings: {
|
||||
language: 2,
|
||||
units: 2,
|
||||
newsletter: 2,
|
||||
compare: 3,
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { urls } from './urls.mjs'
|
||||
import { cloudflare } from './cloudflare.mjs'
|
||||
import { control } from './control.mjs'
|
||||
import { measurements, degreeMeasurements, isDegreeMeasurement } from './measurements.mjs'
|
||||
import { roles } from './roles.mjs'
|
||||
import { urls } from './urls.mjs'
|
||||
|
||||
/*
|
||||
* This top-level file bundles all (named) exports for the config package
|
||||
*/
|
||||
export { control, urls, measurements, degreeMeasurements, isDegreeMeasurement }
|
||||
export { cloudflare, control, measurements, degreeMeasurements, isDegreeMeasurement, roles, urls }
|
||||
|
|
15
packages/config/src/roles.mjs
Normal file
15
packages/config/src/roles.mjs
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const roles = {
|
||||
levels: {
|
||||
readNone: 0,
|
||||
readSome: 1,
|
||||
readOnly: 2,
|
||||
writeSome: 3,
|
||||
user: 4,
|
||||
tester: 4,
|
||||
curator: 5,
|
||||
bughunter: 6,
|
||||
support: 8,
|
||||
admin: 9,
|
||||
},
|
||||
base: 'user',
|
||||
}
|
329
packages/react/components/Account/AccountLinks.mjs
Normal file
329
packages/react/components/Account/AccountLinks.mjs
Normal file
|
@ -0,0 +1,329 @@
|
|||
// Config
|
||||
import { cloudflareImageUrl, capitalize } from '@freesewing/utils'
|
||||
import { control as controlConfig } from '@freesewing/config'
|
||||
// Hooks
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAccount } from '@freesewing/react/hooks/useAccount'
|
||||
import { useBackend } from '@freesewing/react/hooks/useBackend'
|
||||
// Components
|
||||
import { Link as DefautLink } from '@freesewing/react/components/Link'
|
||||
import { ControlScore } from '@freesewing/react/components/Control'
|
||||
import {
|
||||
MeasurementsSetIcon,
|
||||
SignoutIcon,
|
||||
UserIcon,
|
||||
UnitsIcon,
|
||||
ShowcaseIcon,
|
||||
ChatIcon,
|
||||
EmailIcon,
|
||||
KeyIcon,
|
||||
BookmarkIcon,
|
||||
CompareIcon,
|
||||
PrivacyIcon,
|
||||
ControlIcon,
|
||||
LockIcon,
|
||||
NewsletterIcon,
|
||||
ShieldIcon,
|
||||
FingerprintIcon,
|
||||
GitHubIcon,
|
||||
InstagramIcon,
|
||||
MastodonIcon,
|
||||
TwitchIcon,
|
||||
TikTokIcon,
|
||||
LinkIcon,
|
||||
TrashIcon,
|
||||
RedditIcon,
|
||||
CloseIcon,
|
||||
ReloadIcon,
|
||||
NoIcon,
|
||||
PatternIcon,
|
||||
BoolYesIcon,
|
||||
BoolNoIcon,
|
||||
OkIcon,
|
||||
WrenchIcon,
|
||||
UploadIcon,
|
||||
DownloadIcon,
|
||||
} from '@freesewing/react/components/Icon'
|
||||
|
||||
const itemIcons = {
|
||||
bookmarks: <BookmarkIcon />,
|
||||
sets: <MeasurementsSetIcon />,
|
||||
patterns: <PatternIcon />,
|
||||
apikeys: <KeyIcon />,
|
||||
username: <UserIcon />,
|
||||
email: <EmailIcon />,
|
||||
bio: <ChatIcon />,
|
||||
img: <ShowcaseIcon />,
|
||||
language: <ShowcaseIcon />,
|
||||
units: <UnitsIcon />,
|
||||
compare: <CompareIcon />,
|
||||
consent: <PrivacyIcon />,
|
||||
control: <ControlIcon />,
|
||||
mfa: <ShieldIcon />,
|
||||
newsletter: <NewsletterIcon />,
|
||||
password: <LockIcon />,
|
||||
github: <GitHubIcon />,
|
||||
instagram: <InstagramIcon />,
|
||||
mastodon: <MastodonIcon />,
|
||||
twitter: <InstagramIcon />,
|
||||
twitch: <TwitchIcon />,
|
||||
tiktok: <TikTokIcon />,
|
||||
website: <LinkIcon />,
|
||||
reddit: <RedditIcon />,
|
||||
}
|
||||
|
||||
const btnClasses = 'daisy-btn capitalize flex flex-row justify-between'
|
||||
const itemClasses = 'flex flex-row items-center justify-between bg-opacity-10 p-2 px-4 rounded mb-1'
|
||||
|
||||
const titles = {
|
||||
apikeys: 'Your API Keys',
|
||||
bookmarks: 'Your Bookmarks',
|
||||
sets: 'Your Measurements Sets',
|
||||
patterns: 'Your Patterns',
|
||||
img: 'Avatar',
|
||||
email: 'E-mail Address',
|
||||
newsletter: 'Newsletter Subscription',
|
||||
compare: 'Measurements Sets Comparison',
|
||||
consent: 'Consent & Privacy',
|
||||
control: 'User Experience',
|
||||
github: 'GitHub',
|
||||
mfa: 'Multi-Factor Authentication',
|
||||
}
|
||||
|
||||
const AccountLink = ({ item, children, Link }) => (
|
||||
<Link
|
||||
className={`${itemClasses} hover:bg-secondary hover:bg-opacity-10 max-w-md`}
|
||||
href={`/account/${item}/`}
|
||||
title={titles[item] ? titles.item : capitalize(item)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
|
||||
const YesNo = ({ check }) => (check ? <BoolYesIcon /> : <BoolNoIcon />)
|
||||
|
||||
const t = (input) => input
|
||||
|
||||
/**
|
||||
* The AccountLinks component shows all of the links to manage your account
|
||||
*
|
||||
* @param {object} props - All the React props
|
||||
* @param {function} Link - A custom Link component, typically the Docusaurus one, but it's optional
|
||||
*/
|
||||
export const AccountLinks = ({ Link = false }) => {
|
||||
// Use custom Link component if available
|
||||
if (!Link) Link = DefaultLink
|
||||
|
||||
// Hooks
|
||||
const { account, signOut, control } = useAccount()
|
||||
const backend = useBackend()
|
||||
|
||||
// State
|
||||
const [bookmarks, setBookmarks] = useState([])
|
||||
const [sets, setSets] = useState([])
|
||||
const [patterns, setPatterns] = useState([])
|
||||
const [apikeys, setApikeys] = useState([])
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
const getUserData = async () => {
|
||||
const [status, body] = await backend.getUserData(account.id)
|
||||
if (status === 200 && body.result === 'success') {
|
||||
setApikeys(body.data.apikeys)
|
||||
setBookmarks(body.data.bookmarks)
|
||||
setPatterns(body.data.patterns)
|
||||
setSets(body.data.sets)
|
||||
}
|
||||
}
|
||||
getUserData()
|
||||
}, [account.id])
|
||||
|
||||
if (!account.username) return null
|
||||
|
||||
const itemPreviews = {
|
||||
apikeys: apikeys?.length || 0,
|
||||
bookmarks: bookmarks?.length || 0,
|
||||
sets: sets?.length || 0,
|
||||
patterns: patterns?.length || 0,
|
||||
username: account.username,
|
||||
email: account.email,
|
||||
bio: account.bio ? <span>{account.bio.slice(0, 15)}…</span> : '',
|
||||
img: (
|
||||
<img
|
||||
src={cloudflareImageUrl({ type: 'sq100', id: `uid-${account.ihash}` })}
|
||||
className="w-8 h-8 aspect-square rounded-full shadow"
|
||||
/>
|
||||
),
|
||||
units: account.imperial ? 'Imperial' : 'Metric',
|
||||
newsletter: <YesNo check={account.newsletter} />,
|
||||
compare: <YesNo check={account.compare} />,
|
||||
consent: <YesNo check={account.consent} />,
|
||||
control: <ControlScore control={account.control} />,
|
||||
github: account.data.githubUsername || account.data.githubEmail || <NoIcon />,
|
||||
password: account.passwordType === 'v3' ? <BoolYesIcon /> : <NoIcon />,
|
||||
mfa: <YesNo check={account.mfaEnabled} />,
|
||||
}
|
||||
for (const social of Object.keys(controlConfig.account.fields.identities).filter(
|
||||
(i) => i !== 'github'
|
||||
))
|
||||
itemPreviews[social] = account.data[social] || (
|
||||
<NoIcon className="text-base-content w-6 h-6" stroke={2} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-7xl">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 mb-8">
|
||||
<div className="">
|
||||
<h4 className="my-2">Your Data</h4>
|
||||
{Object.keys(controlConfig.account.fields.data).map((item) =>
|
||||
controlConfig.flat[item] > control ? null : (
|
||||
<AccountLink {...{ item, Link }} key={item}>
|
||||
<div className="flex flex-row items-center gap-3 font-medium">
|
||||
{itemIcons[item]}
|
||||
{titles[item] ? titles[item] : capitalize(item)}
|
||||
</div>
|
||||
<div className="">{itemPreviews[item]}</div>
|
||||
</AccountLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{control > 1 && (
|
||||
<div className="">
|
||||
<h4 className="my-2">About You</h4>
|
||||
{Object.keys(controlConfig.account.fields.info).map((item) =>
|
||||
controlConfig.flat[item] > control ? null : (
|
||||
<AccountLink {...{ item, Link }} key={item}>
|
||||
<div className="flex flex-row items-center gap-3 font-medium">
|
||||
{itemIcons[item]}
|
||||
{titles[item] ? titles[item] : capitalize(item)}
|
||||
</div>
|
||||
<div className="">{itemPreviews[item]}</div>
|
||||
</AccountLink>
|
||||
)
|
||||
)}
|
||||
<div className={`${itemClasses} opacity-60 max-w-md`}>
|
||||
<div className="flex flex-row items-center gap-3 font-medium">
|
||||
<OkIcon stroke={3} />
|
||||
<span>Role</span>
|
||||
</div>
|
||||
<div className="capitalize">{account.role}</div>
|
||||
</div>
|
||||
<div className={`${itemClasses} opacity-60 max-w-md`}>
|
||||
<div className="flex flex-row items-center gap-3 font-medium">
|
||||
<FingerprintIcon />
|
||||
<span>ID</span>
|
||||
</div>
|
||||
<div className="">{account.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="">
|
||||
<h4 className="my-2">Preferences</h4>
|
||||
{Object.keys(controlConfig.account.fields.settings).map((item) =>
|
||||
controlConfig.flat[item] > control ? null : (
|
||||
<AccountLink {...{ item, Link }} key={item}>
|
||||
<div className="flex flex-row items-center gap-3 font-medium">
|
||||
{itemIcons[item]}
|
||||
{titles[item] ? titles[item] : capitalize(item)}
|
||||
</div>
|
||||
<div className="">{itemPreviews[item]}</div>
|
||||
</AccountLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{control > 2 && (
|
||||
<div className="">
|
||||
<h4 className="my-2">Linked Identities</h4>
|
||||
{Object.keys(controlConfig.account.fields.identities).map((item) =>
|
||||
controlConfig.flat[item] > control ? null : (
|
||||
<AccountLink {...{ item, Link }} key={item}>
|
||||
<div className="flex flex-row items-center gap-3 font-medium">
|
||||
{itemIcons[item]}
|
||||
{titles[item] ? titles[item] : capitalize(item)}
|
||||
</div>
|
||||
<div className="">{itemPreviews[item]}</div>
|
||||
</AccountLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{control > 1 && (
|
||||
<div className="">
|
||||
<h4 className="my-2">Security</h4>
|
||||
{Object.keys(controlConfig.account.fields.security).map((item) =>
|
||||
controlConfig.flat[item] > control ? null : (
|
||||
<AccountLink {...{ item, Link }} key={item}>
|
||||
<div className="flex flex-row items-center gap-3 font-medium">
|
||||
{itemIcons[item]}
|
||||
{titles[item] ? titles[item] : capitalize(item)}
|
||||
</div>
|
||||
<div className="">{itemPreviews[item]}</div>
|
||||
</AccountLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{control > 1 && (
|
||||
<div className="">
|
||||
<h4 className="my-2">Actions</h4>
|
||||
{control > 2 && (
|
||||
<AccountLink item="import" Link={Link}>
|
||||
<UploadIcon />
|
||||
Import data
|
||||
</AccountLink>
|
||||
)}
|
||||
{control > 2 && (
|
||||
<AccountLink item="export" Link={Link}>
|
||||
<DownloadIcon />
|
||||
Export your data
|
||||
</AccountLink>
|
||||
)}
|
||||
{control > 2 && (
|
||||
<AccountLink item="reload" Link={Link}>
|
||||
<ReloadIcon />
|
||||
Reload account data
|
||||
</AccountLink>
|
||||
)}
|
||||
{control > 3 && (
|
||||
<AccountLink item="restrict" Link={Link}>
|
||||
<CloseIcon className="w-6 h-6 text-warning" stroke={3} />
|
||||
Restrict processing of your data
|
||||
</AccountLink>
|
||||
)}
|
||||
<AccountLink item="remove" Link={Link}>
|
||||
<TrashIcon className="w-6 h-6 text-warning" />
|
||||
Remove your account
|
||||
</AccountLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-2 md:gap-4 justify-end">
|
||||
{account.role === 'admin' && (
|
||||
<Link className={`${btnClasses} daisy-btn-accent md:w-64 w-full`} href="/admin">
|
||||
<WrenchIcon />
|
||||
Administration
|
||||
</Link>
|
||||
)}
|
||||
{control > 1 && (
|
||||
<Link className={`${btnClasses} daisy-btn-secondary md:w-64 w-full`} href="/profile">
|
||||
<UserIcon />
|
||||
{t('yourProfile')}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className={`${btnClasses} daisy-btn-neutral md:w-64 w-full`}
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
<SignoutIcon />
|
||||
{t('signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
408
packages/react/components/Account/apikeys.mjs
Normal file
408
packages/react/components/Account/apikeys.mjs
Normal file
|
@ -0,0 +1,408 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
98
packages/react/components/Account/bio.mjs
Normal file
98
packages/react/components/Account/bio.mjs
Normal file
|
@ -0,0 +1,98 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
246
packages/react/components/Account/bookmarks.mjs
Normal file
246
packages/react/components/Account/bookmarks.mjs
Normal file
|
@ -0,0 +1,246 @@
|
|||
// Context
|
||||
import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs'
|
||||
// Hooks
|
||||
import { useState, useEffect, Fragment, useContext } from 'react'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||
import { useRouter } from 'next/router'
|
||||
// Components
|
||||
import { BackToAccountButton } from './shared.mjs'
|
||||
import { PlusIcon, TrashIcon, LeftIcon } from 'shared/components/icons.mjs'
|
||||
import { PageLink, WebLink, Link } from 'shared/components/link.mjs'
|
||||
import { DisplayRow } from './shared.mjs'
|
||||
import { StringInput } from 'shared/components/inputs.mjs'
|
||||
import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs'
|
||||
|
||||
export const ns = ['account', 'status']
|
||||
|
||||
export const types = ['design', 'pattern', 'set', 'cset', 'doc', 'custom']
|
||||
|
||||
export const Bookmark = ({ bookmark }) => {
|
||||
const { t } = useTranslation(ns)
|
||||
|
||||
return bookmark ? (
|
||||
<div>
|
||||
<DisplayRow title={t('title')}>{bookmark.title}</DisplayRow>
|
||||
<DisplayRow title={t('url')}>
|
||||
{bookmark.url.length > 30 ? bookmark.url.slice(0, 30) + '...' : bookmark.url}
|
||||
</DisplayRow>
|
||||
<DisplayRow title={t('type')}>{t(`${bookmark.type}Bookmark`)}</DisplayRow>
|
||||
<div className="flex flex-row flex-wrap md:gap-2 md:items-center md:justify-between mt-8">
|
||||
<Link
|
||||
href="/account/bookmarks"
|
||||
className="w-full md:w-auto btn btn-secondary pr-6 flex flex-row items-center gap-2"
|
||||
>
|
||||
<LeftIcon />
|
||||
{t('bookmarks')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
// Component for the 'new/apikey' page
|
||||
export const NewBookmark = () => {
|
||||
// Hooks
|
||||
const { setLoadingStatus } = useContext(LoadingStatusContext)
|
||||
const router = useRouter()
|
||||
const backend = useBackend()
|
||||
const { t, i18n } = useTranslation(ns)
|
||||
const docs = {}
|
||||
for (const option of ['title', 'location', 'type']) {
|
||||
docs[option] = (
|
||||
<DynamicMdx language={i18n.language} slug={`docs/about/site/bookmarks/${option}`} />
|
||||
)
|
||||
}
|
||||
|
||||
// State
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
|
||||
const createBookmark = async () => {
|
||||
setLoadingStatus([true, 'processingUpdate'])
|
||||
const result = await backend.createBookmark({
|
||||
title,
|
||||
url,
|
||||
type: 'custom',
|
||||
})
|
||||
if (result.success) {
|
||||
setLoadingStatus([true, 'nailedIt', true, true])
|
||||
router.push('/account/bookmarks')
|
||||
} else setLoadingStatus([true, 'backendError', true, false])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl xl:pl-4">
|
||||
<StringInput
|
||||
id="bookmark-title"
|
||||
label={t('title')}
|
||||
docs={docs.title}
|
||||
update={setTitle}
|
||||
current={title}
|
||||
valid={(val) => val.length > 0}
|
||||
placeholder={t('account')}
|
||||
/>
|
||||
<StringInput
|
||||
id="bookmark-url"
|
||||
label={t('location')}
|
||||
docs={docs.location}
|
||||
update={setUrl}
|
||||
current={url}
|
||||
valid={(val) => val.length > 0}
|
||||
placeholder={'https://freesewing.org/account'}
|
||||
/>
|
||||
<div className="flex flex-row gap-2 items-center w-full my-8">
|
||||
<button
|
||||
className="btn btn-primary grow capitalize"
|
||||
disabled={!(title.length > 0 && url.length > 0)}
|
||||
onClick={createBookmark}
|
||||
>
|
||||
{t('newBookmark')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Component for the account/bookmarks page
|
||||
export const Bookmarks = () => {
|
||||
// Hooks
|
||||
const backend = useBackend()
|
||||
const { t } = useTranslation(ns)
|
||||
const { setLoadingStatus, LoadingProgress } = useContext(LoadingStatusContext)
|
||||
|
||||
// State
|
||||
const [bookmarks, setBookmarks] = 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 getBookmarks = async () => {
|
||||
const result = await backend.getBookmarks()
|
||||
if (result.success) setBookmarks(result.data.bookmarks)
|
||||
}
|
||||
getBookmarks()
|
||||
}, [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 === bookmarks.length) setSelected({})
|
||||
else {
|
||||
const newSelected = {}
|
||||
for (const bookmark of bookmarks) newSelected[bookmark.id] = 1
|
||||
setSelected(newSelected)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to delete one or more bookmarks
|
||||
const removeSelectedBookmarks = async () => {
|
||||
let i = 0
|
||||
for (const id in selected) {
|
||||
i++
|
||||
await backend.removeBookmark(id)
|
||||
setLoadingStatus([
|
||||
true,
|
||||
<LoadingProgress val={i} max={selCount} msg={t('removingBookmarks')} key="linter" />,
|
||||
])
|
||||
}
|
||||
setSelected({})
|
||||
setRefresh(refresh + 1)
|
||||
setLoadingStatus([true, 'nailedIt', true, true])
|
||||
}
|
||||
|
||||
const perType = {}
|
||||
for (const type of types) perType[type] = bookmarks.filter((b) => b.type === type)
|
||||
|
||||
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/bookmark"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('newBookmark')}
|
||||
</Link>
|
||||
</p>
|
||||
{bookmarks.length > 0 ? (
|
||||
<button className="btn btn-error" onClick={removeSelectedBookmarks} disabled={selCount < 1}>
|
||||
<TrashIcon /> {selCount} {t('bookmarks')}
|
||||
</button>
|
||||
) : null}
|
||||
{types.map((type) =>
|
||||
perType[type].length > 0 ? (
|
||||
<Fragment key={type}>
|
||||
<h2>{t(`${type}Bookmark`)}</h2>
|
||||
<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={bookmarks.length === selCount}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-base-300 text-base">{t('title')}</th>
|
||||
<th className="text-base-300 text-base">{t('location')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookmarks
|
||||
.filter((bookmark) => bookmark.type === type)
|
||||
.map((bookmark, i) => (
|
||||
<tr key={i}>
|
||||
<td className="text-base font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected[bookmark.id] ? true : false}
|
||||
className="checkbox checkbox-secondary"
|
||||
onClick={() => toggleSelect(bookmark.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-base font-medium">
|
||||
<PageLink
|
||||
href={`/account/bookmark?id=${bookmark.id}`}
|
||||
txt={bookmark.title}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-base font-medium">
|
||||
<WebLink
|
||||
href={bookmark.url}
|
||||
txt={
|
||||
bookmark.url.length > 30
|
||||
? bookmark.url.slice(0, 30) + '...'
|
||||
: bookmark.url
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-base font-medium"></td>
|
||||
<td className="text-base font-medium hidden md:block"></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Fragment>
|
||||
) : null
|
||||
)}
|
||||
<BackToAccountButton />
|
||||
</div>
|
||||
)
|
||||
}
|
98
packages/react/components/Account/compare.mjs
Normal file
98
packages/react/components/Account/compare.mjs
Normal file
|
@ -0,0 +1,98 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
116
packages/react/components/Account/consent.mjs
Normal file
116
packages/react/components/Account/consent.mjs
Normal file
|
@ -0,0 +1,116 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
105
packages/react/components/Account/control.mjs
Normal file
105
packages/react/components/Account/control.mjs
Normal file
|
@ -0,0 +1,105 @@
|
|||
// __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>
|
||||
)
|
||||
}
|
76
packages/react/components/Account/email.mjs
Normal file
76
packages/react/components/Account/email.mjs
Normal file
|
@ -0,0 +1,76 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
49
packages/react/components/Account/export.mjs
Normal file
49
packages/react/components/Account/export.mjs
Normal file
|
@ -0,0 +1,49 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
35
packages/react/components/Account/force-account-check.mjs
Normal file
35
packages/react/components/Account/force-account-check.mjs
Normal file
|
@ -0,0 +1,35 @@
|
|||
// 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
|
||||
}
|
62
packages/react/components/Account/github.mjs
Normal file
62
packages/react/components/Account/github.mjs
Normal file
|
@ -0,0 +1,62 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
88
packages/react/components/Account/img.mjs
Normal file
88
packages/react/components/Account/img.mjs
Normal file
|
@ -0,0 +1,88 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
90
packages/react/components/Account/imperial.mjs
Normal file
90
packages/react/components/Account/imperial.mjs
Normal file
|
@ -0,0 +1,90 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
101
packages/react/components/Account/import.mjs
Normal file
101
packages/react/components/Account/import.mjs
Normal file
|
@ -0,0 +1,101 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
5
packages/react/components/Account/index.mjs
Normal file
5
packages/react/components/Account/index.mjs
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
|
||||
import { AccountLinks } from './AccountLinks.mjs'
|
||||
|
||||
export { AccountLinks }
|
66
packages/react/components/Account/language.mjs
Normal file
66
packages/react/components/Account/language.mjs
Normal file
|
@ -0,0 +1,66 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
187
packages/react/components/Account/mfa.mjs
Normal file
187
packages/react/components/Account/mfa.mjs
Normal file
|
@ -0,0 +1,187 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
107
packages/react/components/Account/newsletter.mjs
Normal file
107
packages/react/components/Account/newsletter.mjs
Normal file
|
@ -0,0 +1,107 @@
|
|||
// 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
|
3
packages/react/components/Account/overview.mjs
Normal file
3
packages/react/components/Account/overview.mjs
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { AccountLinks } from './links.mjs'
|
||||
|
||||
export const AccountOverview = ({ app }) => <AccountLinks app={app} />
|
66
packages/react/components/Account/password.mjs
Normal file
66
packages/react/components/Account/password.mjs
Normal file
|
@ -0,0 +1,66 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
725
packages/react/components/Account/patterns.mjs
Normal file
725
packages/react/components/Account/patterns.mjs
Normal file
|
@ -0,0 +1,725 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
55
packages/react/components/Account/platform.mjs
Normal file
55
packages/react/components/Account/platform.mjs
Normal file
|
@ -0,0 +1,55 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
54
packages/react/components/Account/profile.mjs
Normal file
54
packages/react/components/Account/profile.mjs
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Hooks
|
||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||
// Components
|
||||
import Link from 'next/link'
|
||||
import { Mdx } from 'shared/components/mdx/dynamic.mjs'
|
||||
import { HeartIcon } from 'shared/components/icons.mjs'
|
||||
import { ModalWrapper } from 'shared/components/wrappers/modal.mjs'
|
||||
|
||||
export const ns = ['account']
|
||||
|
||||
export const Avatar = ({ img, app = false }) => (
|
||||
<div className={`mask mask-squircle bg-neutral z-10 ${app ? 'w-24' : 'w-full'}`}>
|
||||
<img
|
||||
src={img}
|
||||
onClick={
|
||||
app
|
||||
? () =>
|
||||
app.setModal(
|
||||
<ModalWrapper app={app}>
|
||||
<Avatar img={img} />
|
||||
</ModalWrapper>
|
||||
)
|
||||
: null
|
||||
}
|
||||
className={app ? 'hover:cursor-zoom-in' : 'hover:cursor-zoom-out'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const AccountProfile = ({ app }) => {
|
||||
const { account } = useAccount()
|
||||
|
||||
if (!account) return null
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<div className="flex flex-row w-full justify-center">
|
||||
<Avatar img={account.img} app={app} />
|
||||
{!account.patron ? (
|
||||
<Link href="/patrons/join" className="z-20">
|
||||
<HeartIcon className="w-12 h-12 -ml-8 mt-2 stroke-base-100 fill-accent" stroke={1} />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<h2 className="text-center">{account.username}</h2>
|
||||
<div className="flex flex-row">
|
||||
<div className="avatar -mt-6 -ml-8 flex flex-row items-end"></div>
|
||||
</div>
|
||||
<div className="max-w-full truncate">
|
||||
<Mdx md={account.bio} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
40
packages/react/components/Account/reload.mjs
Normal file
40
packages/react/components/Account/reload.mjs
Normal file
|
@ -0,0 +1,40 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
42
packages/react/components/Account/remove.mjs
Normal file
42
packages/react/components/Account/remove.mjs
Normal file
|
@ -0,0 +1,42 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
43
packages/react/components/Account/restrict.mjs
Normal file
43
packages/react/components/Account/restrict.mjs
Normal file
|
@ -0,0 +1,43 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
28
packages/react/components/Account/role.mjs
Normal file
28
packages/react/components/Account/role.mjs
Normal file
|
@ -0,0 +1,28 @@
|
|||
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>
|
||||
)
|
||||
}
|
1253
packages/react/components/Account/sets.mjs
Normal file
1253
packages/react/components/Account/sets.mjs
Normal file
File diff suppressed because it is too large
Load diff
136
packages/react/components/Account/shared.mjs
Normal file
136
packages/react/components/Account/shared.mjs
Normal file
|
@ -0,0 +1,136 @@
|
|||
// __SDEFILE__ - This file is a dependency for the stand-alone environment
|
||||
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,
|
||||
}
|
||||
|
||||
export const welcomeSteps = {
|
||||
1: [''],
|
||||
2: ['', 'newsletter', 'units'],
|
||||
3: ['', 'newsletter', 'units', 'compare', 'username'],
|
||||
4: ['', 'newsletter', 'units', 'compare', 'username', 'bio', 'img'],
|
||||
5: [''],
|
||||
}
|
||||
|
||||
export const DisplayRow = ({ title, children, keyWidth = 'w-24' }) => (
|
||||
<div className="flex flex-row flex-wrap items-center lg:gap-4 my-2 w-full">
|
||||
<div className={`${keyWidth} text-left md:text-right block md:inline font-bold pr-4 shrink-0`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="grow">{children}</div>
|
||||
</div>
|
||||
)
|
22
packages/react/components/Account/status.mjs
Normal file
22
packages/react/components/Account/status.mjs
Normal file
|
@ -0,0 +1,22 @@
|
|||
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>
|
||||
)
|
||||
}
|
109
packages/react/components/Account/username.mjs
Normal file
109
packages/react/components/Account/username.mjs
Normal file
|
@ -0,0 +1,109 @@
|
|||
// 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="max-w-xl">
|
||||
<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>
|
||||
)
|
||||
}
|
13
packages/react/components/Control/index.mjs
Normal file
13
packages/react/components/Control/index.mjs
Normal file
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import { BulletIcon } from '@freesewing/react/components/Icon'
|
||||
|
||||
const scores = [1, 2, 3, 4, 5]
|
||||
|
||||
export const ControlScore = ({ control, color = 'base-content' }) =>
|
||||
control ? (
|
||||
<div className={`flex flex-row items-center text-${color}`}>
|
||||
{scores.map((score) => (
|
||||
<BulletIcon fill={control >= score ? true : false} className="w-6 h-6 -ml-1" key={score} />
|
||||
))}
|
||||
</div>
|
||||
) : null
|
|
@ -71,6 +71,13 @@ export const BookmarkIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a circle
|
||||
export const BulletIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks lik a speech bubble
|
||||
export const ChatIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -92,6 +99,20 @@ export const CloseIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// FIXME
|
||||
export const CompareIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like scales of justice
|
||||
export const ControlIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0012 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52l2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 01-2.031.352 5.988 5.988 0 01-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0l2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 01-2.031.352 5.989 5.989 0 01-2.031-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a museum building
|
||||
export const CuratedMeasurementsSetIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -132,6 +153,13 @@ export const DownIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a cloud with an arrow pointing down from it
|
||||
export const DownloadIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M12 9.75v6.75m0 0l-3-3m3 3l3-3m-8.25 6a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a pencil
|
||||
export const EditIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -172,6 +200,13 @@ export const FailureIcon = ({ size = 6 }) => (
|
|||
<NoIcon className={`w-${size} h-${size} text-secondary-content`} stroke={4} />
|
||||
)
|
||||
|
||||
// Looks like a fingerprint
|
||||
export const FingerprintIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks lik a flag
|
||||
export const FlagIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -230,6 +265,13 @@ export const IncludeIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like the Instagram logo
|
||||
export const InstagramIcon = (props) => (
|
||||
<IconWrapper {...props} stroke={0} fill>
|
||||
<path d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a key
|
||||
export const KeyIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -251,6 +293,13 @@ export const LeftIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a chain link
|
||||
export const LinkIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a bullet list
|
||||
export const ListIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -272,6 +321,13 @@ export const MarginIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like the Mastodon logo
|
||||
export const MastodonIcon = (props) => (
|
||||
<IconWrapper {...props} fill stroke={0}>
|
||||
<path d="m 11.217423,0.1875 c -2.8267978,0.0231106 -5.545964,0.32921539 -7.1306105,1.056962 0,0 -3.14282962,1.4058166 -3.14282962,6.2023445 0,1.0983506 -0.021349,2.4116171 0.013437,3.8043315 0.11412502,4.690743 0.85993502,9.313695 5.19692442,10.461603 1.9996899,0.529281 3.7166529,0.640169 5.0993757,0.564166 2.507534,-0.139021 3.915187,-0.894849 3.915187,-0.894849 l -0.08272,-1.819364 c 0,0 -1.79194,0.564966 -3.804377,0.496111 -1.9938518,-0.06838 -4.0987697,-0.214969 -4.4212502,-2.662908 -0.029782,-0.215025 -0.044673,-0.445024 -0.044673,-0.686494 0,0 1.9573364,0.47844 4.4378282,0.592088 1.516743,0.06957 2.939062,-0.08886 4.383732,-0.261231 2.770451,-0.330816 5.182722,-2.037815 5.485905,-3.597546 0.477704,-2.456993 0.438356,-5.9959075 0.438356,-5.9959075 0,-4.7965279 -3.142655,-6.2023445 -3.142655,-6.2023445 C 16.83453,0.51671539 14.113674,0.21061063 11.286876,0.1875 Z M 8.0182292,3.9352913 c 1.177465,0 2.0690118,0.4525587 2.6585778,1.3578046 l 0.573249,0.9608111 0.573247,-0.9608111 c 0.589448,-0.9052459 1.480995,-1.3578046 2.65858,-1.3578046 1.017594,0 1.837518,0.3577205 2.463657,1.0555661 0.606959,0.6978459 0.909169,1.6411822 0.909169,2.8281631 V 13.626816 H 15.553691 V 7.9896839 c 0,-1.1882914 -0.49996,-1.7914432 -1.500043,-1.7914432 -1.10575,0 -1.659889,0.715401 -1.659889,2.1301529 V 11.413948 H 10.106352 V 8.3283936 c 0,-1.4147519 -0.5543138,-2.1301529 -1.6600628,-2.1301529 -1.000084,0 -1.5000426,0.6031518 -1.5000426,1.7914432 V 13.626816 H 4.6452275 V 7.8190205 c 0,-1.1869809 0.3022656,-2.1303172 0.9093441,-2.8281631 C 6.1805914,4.2930118 7.0005147,3.9352913 8.0182292,3.9352913 Z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a tape measure
|
||||
export const MeasurementsIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -293,6 +349,13 @@ export const MenuIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a newspaper
|
||||
export const NewsletterIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a X
|
||||
export const NoIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -325,6 +388,13 @@ export const PaperlessIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a page
|
||||
export const PatternIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a +
|
||||
export const PlusIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -339,6 +409,27 @@ export const PrintIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// FIXME
|
||||
export const PrivacyIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like the Reddit alian
|
||||
export const RedditIcon = (props) => (
|
||||
<IconWrapper {...props} stroke={0} fill>
|
||||
<path d="M 11.710829,0.00384705 C 5.0683862,0.16990815 -0.16221405,5.6505729 0.00384705,12.293016 0.16990814,18.686369 5.3178021,23.833614 11.628124,24.082706 18.270567,24.248767 23.833939,19.018167 24,12.375723 V 11.710829 C 23.833939,5.0683862 18.353273,-0.16221404 11.710829,0.00384705 Z m 5.187788,5.10021625 c 0.15698,0.00649 0.313636,0.048326 0.458939,0.1313569 0.581214,0.3321223 0.912687,1.0793971 0.580565,1.660611 C 17.605998,7.4772452 16.858724,7.808718 16.27751,7.4765965 15.862357,7.3105352 15.614238,6.8947339 15.614238,6.3965506 L 13.038995,5.8159854 12.208689,9.55236 c 1.826672,0.08303 3.48858,0.664893 4.651007,1.495199 0.664245,-0.664245 1.826673,-0.664245 2.490917,0 0.332122,0.332121 0.49786,0.747274 0.49786,1.245457 0.249091,0.747275 -0.249092,1.327193 -0.830306,1.576284 v 0.49948 c 0,2.740009 -3.155161,4.897506 -7.057597,4.897506 -3.9024357,0 -7.0575963,-2.157497 -7.0575963,-4.897506 V 13.8693 C 3.9896377,13.454147 3.6578398,12.458754 3.989962,11.545418 c 0.2490916,-0.664245 0.9120387,-1.08037 1.5762832,-0.99734 0.4981831,0 0.9133359,0.167358 1.2454581,0.499481 C 8.2232228,10.134222 9.8848065,9.55236 11.545418,9.55236 l 0.913011,-4.1515273 c 0,-0.083031 0.08271,-0.1654124 0.08271,-0.1654125 0.08303,-0.08303 0.166711,-0.084328 0.249741,-0.084328 l 2.906069,0.664893 C 15.946037,5.3800751 16.427678,5.084603 16.898617,5.1040633 Z M 9.3026198,12.293016 c -0.6642443,0 -1.2454583,0.581214 -1.2454583,1.245458 0,0.664245 0.498183,1.245459 1.2454583,1.245459 0.6642442,0 1.2454582,-0.581214 1.2454582,-1.245459 0,-0.664244 -0.581214,-1.245458 -1.2454582,-1.245458 z m 5.4813132,0 c -0.664245,0 -1.245459,0.581214 -1.245459,1.245458 0,0.664245 0.581214,1.245459 1.245459,1.245459 0.664245,0 1.245458,-0.581214 1.245458,-1.245459 0,-0.664244 -0.581213,-1.245458 -1.245458,-1.245458 z m -5.3872557,3.943952 c -0.072653,0 -0.135249,0.04021 -0.1767645,0.123249 -0.1660605,0.16606 -0.1660605,0.332121 0,0.415152 0.8303052,0.830306 2.4905922,0.914633 2.9887762,0.914633 0.498183,0 2.077061,-0.08433 2.990396,-0.914633 -0.08303,-0.08303 -0.084,-0.249092 -0.167034,-0.415152 -0.166061,-0.166062 -0.332121,-0.166062 -0.415152,0 -0.498183,0.581213 -1.660611,0.747598 -2.490917,0.747598 -0.830305,0 -1.992733,-0.166385 -2.4909165,-0.747598 -0.08303,-0.08303 -0.1657365,-0.123249 -0.2383882,-0.123249 z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// FIXME
|
||||
export const ReloadIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a single rewind arrow
|
||||
export const ResetIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -413,6 +504,28 @@ export const SettingsIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a shield
|
||||
export const ShieldIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a picture camera
|
||||
export const ShowcaseIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like an exit door
|
||||
export const SignoutIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a spinning circle
|
||||
export const SpinnerIcon = (props) => (
|
||||
<IconWrapper
|
||||
|
@ -441,6 +554,13 @@ export const SuccessIcon = ({ size = 6 }) => (
|
|||
<OkIcon className={`w-${size} h-${size} text-secondary-content`} stroke={4} />
|
||||
)
|
||||
|
||||
// Looks like the TikTok t
|
||||
export const TikTokIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
<path d="M 21.070629,5.6224629 A 5.7508474,5.7508474 0 0 1 16.547219,0.52913011 V 0 H 12.41376 v 16.404252 a 3.474745,3.474745 0 0 1 -6.2403831,2.091334 l -0.0024,-0.0012 0.0024,0.0012 A 3.4735455,3.4735455 0 0 1 9.9924767,13.084289 V 8.8848362 A 7.5938063,7.5938063 0 0 0 3.5205237,21.713559 7.5950059,7.5950059 0 0 0 16.547219,16.405452 V 8.0233494 a 9.8171151,9.8171151 0 0 0 5.72685,1.8309665 V 5.7472464 A 5.7964413,5.7964413 0 0 1 21.070637,5.6225887 Z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a light bulb
|
||||
export const TipIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
@ -455,6 +575,13 @@ export const TrashIcon = (props) => (
|
|||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like the twitch logo
|
||||
export const TwitchIcon = (props) => (
|
||||
<IconWrapper {...props} stroke={0} fill>
|
||||
<path d="M2.149 0l-1.612 4.119v16.836h5.731v3.045h3.224l3.045-3.045h4.657l6.269-6.269v-14.686h-21.314zm19.164 13.612l-3.582 3.582h-5.731l-3.045 3.045v-3.045h-4.836v-15.045h17.194v11.463zm-3.582-7.343v6.262h-2.149v-6.262h2.149zm-5.731 0v6.262h-2.149v-6.262h2.149z" />
|
||||
</IconWrapper>
|
||||
)
|
||||
|
||||
// Looks like a desktop screen
|
||||
export const UiIcon = (props) => (
|
||||
<IconWrapper {...props}>
|
||||
|
|
238
packages/react/components/Role/index.mjs
Normal file
238
packages/react/components/Role/index.mjs
Normal file
|
@ -0,0 +1,238 @@
|
|||
import { horFlexClasses } from '@freesewing/utils'
|
||||
import { roles } from '@freesewing/config'
|
||||
//Hooks
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useAccount } from '@freesewing/react/hooks/useAccount'
|
||||
import { useBackend } from '@freesewing/react/hooks/useBackend'
|
||||
// Components
|
||||
import { Link as DefaultLink } from '@freesewing/react/components/Link'
|
||||
import { LockIcon, PlusIcon } from '@freesewing/react/components/Icon'
|
||||
import { Spinner } from '@freesewing/react/components/Spinner'
|
||||
|
||||
//import { ConsentForm, ns as gdprNs } from 'shared/components/gdpr/form.mjs'
|
||||
|
||||
const ConsentForm = () => null
|
||||
|
||||
const Wrap = ({ children }) => (
|
||||
<div className="m-auto max-w-xl text-center mt-8 p-8">{children}</div>
|
||||
)
|
||||
|
||||
const ContactSupport = ({ Link = false }) => {
|
||||
if (!Link) Link = DefaultLink
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
||||
<Link href="/support" className="btn btn-success w-full">
|
||||
{t('contactSupport')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AuthRequired = ({ Link, banner }) => {
|
||||
if (!Link) Link = DefaultLink
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
{banner}
|
||||
<h2>Authentication Required</h2>
|
||||
<p>This functionality requires a FreeSewing account</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mt-8">
|
||||
<Link href="/signup" className={`${horFlexClasses} daisy-btn daisy-btn-secondary w-full`}>
|
||||
<PlusIcon />
|
||||
Sign Up
|
||||
</Link>
|
||||
<Link
|
||||
href="/signin"
|
||||
className={`${horFlexClasses} daisy-btn daisy-btn-secondary daisy-btn-outline w-full`}
|
||||
>
|
||||
<LockIcon />
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const AccountInactive = ({ Link, banner }) => {
|
||||
if (!Link) Link = DefaultLink
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
{banner}
|
||||
<h1>{t('accountInactive')}</h1>
|
||||
<p>{t('accountInactiveMsg')}</p>
|
||||
<p>{t('signupAgain')}</p>
|
||||
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
||||
<Link href="/signup" className="btn btn-primary w-full">
|
||||
{t('signUp')}
|
||||
</Link>
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const AccountDisabled = ({ banner }) => (
|
||||
<Wrap>
|
||||
{banner}
|
||||
<h1>{t('accountDisabled')}</h1>
|
||||
<p>{t('accountDisabledMsg')}</p>
|
||||
<ContactSupport t={t} />
|
||||
</Wrap>
|
||||
)
|
||||
|
||||
const AccountProhibited = ({ banner }) => (
|
||||
<Wrap>
|
||||
{banner}
|
||||
<h1>{t('accountProhibited')}</h1>
|
||||
<p>{t('accountProhibitedMsg')}</p>
|
||||
<ContactSupport t={t} />
|
||||
</Wrap>
|
||||
)
|
||||
|
||||
const AccountStatusUnknown = ({ t, banner }) => (
|
||||
<Wrap>
|
||||
{banner}
|
||||
<h1>{t('statusUnknown')}</h1>
|
||||
<p>{t('statusUnknownMsg')}</p>
|
||||
<ContactSupport t={t} />
|
||||
</Wrap>
|
||||
)
|
||||
|
||||
const RoleLacking = ({ t, requiredRole, role, banner }) => (
|
||||
<Wrap>
|
||||
{banner}
|
||||
<h1>{t('roleLacking')}</h1>
|
||||
<p dangerouslySetInnerHTML={{ __html: t('roleLackingMsg', { requiredRole, role }) }} />
|
||||
<ContactSupport t={t} />
|
||||
</Wrap>
|
||||
)
|
||||
|
||||
const ConsentLacking = ({ banner, refresh }) => {
|
||||
const { setAccount, setToken, setSeenUser } = useAccount()
|
||||
const backend = useBackend()
|
||||
|
||||
const updateConsent = async ({ consent1, consent2 }) => {
|
||||
let consent = 0
|
||||
if (consent1) consent = 1
|
||||
if (consent1 && consent2) consent = 2
|
||||
if (consent > 0) {
|
||||
const result = await backend.updateConsent(consent)
|
||||
if (result.success) {
|
||||
setToken(result.data.token)
|
||||
setAccount({ ...result.data.account, bestBefore: Date.now() + 3600000 })
|
||||
setSeenUser(result.data.account.username)
|
||||
refresh()
|
||||
} else {
|
||||
console.log('something went wrong', result)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<div className="text-left">
|
||||
{banner}
|
||||
<ConsentForm submit={updateConsent} />
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const t = (input) => input
|
||||
|
||||
export const RoleBlock = ({ children, user = false, Link = false }) => {
|
||||
if (!Link) Link = DefaultLink
|
||||
let requiredRole = 'admin'
|
||||
if (user) requiredRole = user
|
||||
|
||||
const { account, setAccount, token, admin, stopImpersonating, signOut } = useAccount()
|
||||
const backend = useBackend()
|
||||
|
||||
const [ready, setReady] = useState(false)
|
||||
const [impersonating, setImpersonating] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
|
||||
/*
|
||||
* Avoid hydration errors
|
||||
*/
|
||||
useEffect(() => {
|
||||
const verifyAdmin = async () => {
|
||||
const result = await backend.adminPing(admin.token)
|
||||
if (result.success && result.data.account.role === 'admin') {
|
||||
setImpersonating({
|
||||
admin: result.data.account.username,
|
||||
user: account.username,
|
||||
})
|
||||
}
|
||||
setReady(true)
|
||||
}
|
||||
const verifyUser = async () => {
|
||||
const [status, data] = await backend.ping()
|
||||
if (status === 200 && data.result === 'success') {
|
||||
// Refresh account in local storage
|
||||
setAccount({
|
||||
...account,
|
||||
...data.account,
|
||||
bestBefore: Date.now() + 3600000,
|
||||
})
|
||||
} else {
|
||||
if (data?.error?.error) setError(data.error.error)
|
||||
else {
|
||||
console.log('WOULD SIGN OUT', data)
|
||||
}
|
||||
//else signOut()
|
||||
}
|
||||
setReady(true)
|
||||
}
|
||||
if (admin && admin.token) verifyAdmin()
|
||||
if (token) {
|
||||
// Don't hammer the backend. Check once per hour.
|
||||
if (!account.bestBefore || account.bestBefore < Date.now()) verifyUser()
|
||||
}
|
||||
setReady(true)
|
||||
}, [admin, refreshCount, signOut])
|
||||
|
||||
const refresh = () => {
|
||||
setRefreshCount(refreshCount + 1)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
if (!ready)
|
||||
return (
|
||||
<>
|
||||
<p>not ready</p>
|
||||
<Spinner />
|
||||
</>
|
||||
)
|
||||
|
||||
const banner = impersonating ? (
|
||||
<div className="bg-warning rounded-lg shadow py-4 px-6 flex flex-row items-center gap-4 justify-between">
|
||||
<span className="text-base-100 text-left">
|
||||
Hi <b>{impersonating.admin}</b>, you are currently impersonating <b>{impersonating.user}</b>
|
||||
</span>
|
||||
<button className="btn btn-neutral" onClick={stopImpersonating}>
|
||||
Stop Impersonating
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const childProps = { t, banner }
|
||||
|
||||
if (!token || !account.username) return <AuthRequired {...childProps} />
|
||||
if (error) {
|
||||
if (error === 'accountInactive') return <AccountInactive {...childProps} />
|
||||
if (error === 'accountDisabled') return <AccountDisabled {...childProps} />
|
||||
if (error === 'accountBlocked') return <AccountProhibited {...childProps} />
|
||||
if (error === 'consentLacking') return <ConsentLacking {...childProps} refresh={refresh} />
|
||||
return <AccountStatusUnknown {...childProps} />
|
||||
}
|
||||
|
||||
if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) {
|
||||
return <RoleLacking {...childProps} role={account.role} requiredRole={requiredRole} />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
|
@ -24,7 +24,7 @@ import { MfaInput, StringInput, PasswordInput } from '@freesewing/react/componen
|
|||
* This SignIn component holds the entire sign-in form
|
||||
*
|
||||
* @param {object} props - All React props
|
||||
* @param {function} props.onSuccess - A method to run when the sign in is successful
|
||||
* @param {function} props.onSuccess - Optional: A method to run when the sign in is successful
|
||||
*/
|
||||
export const SignIn = ({ onSuccess = false }) => {
|
||||
const { setAccount, setToken, seenUser, setSeenUser } = useAccount()
|
||||
|
@ -64,7 +64,6 @@ export const SignIn = ({ onSuccess = false }) => {
|
|||
const signinHandler = async (evt) => {
|
||||
evt.preventDefault()
|
||||
setLoadingStatus([true, 'Contacting FreeSewing backend'])
|
||||
console.log({ magicLink })
|
||||
const result = magicLink
|
||||
? await backend.signIn({ username, password: false })
|
||||
: await backend.signIn({ username, password, token: mfaCode })
|
||||
|
@ -89,7 +88,7 @@ export const SignIn = ({ onSuccess = false }) => {
|
|||
setSeenUser(body.account.username)
|
||||
setLoadingStatus([true, `Welcome back ${body.account.username}`, true, true])
|
||||
// Call the onSuccess handler
|
||||
onSuccess(body)
|
||||
if (typeof onSuccess === 'function') onSuccess(body)
|
||||
}
|
||||
}
|
||||
// Sign-in failed
|
||||
|
|
|
@ -5,7 +5,7 @@ import { defaultControlLevel } from '@freesewing/react/config/freesewing'
|
|||
* When there is no account, we use this making it easy to check for username
|
||||
* or control
|
||||
*/
|
||||
const noAccount = { username: false, control: defaultControlLevel }
|
||||
const noAccount = { username: false, control: defaultControlLevel, these: 'nuts' }
|
||||
|
||||
/*
|
||||
* FreeSewing's useAccount hook. Grants access to the (data in the) user's account
|
||||
|
@ -39,6 +39,7 @@ export function useAccount() {
|
|||
* Clear user data when signing out
|
||||
*/
|
||||
const signOut = () => {
|
||||
console.log('SIGN OUT')
|
||||
setAccount(noAccount)
|
||||
setToken(null)
|
||||
}
|
||||
|
|
|
@ -102,11 +102,7 @@ export function useBackend() {
|
|||
* @return {object} headers - An object holding headers for the REST API call
|
||||
*/
|
||||
function authenticationHeaders(token) {
|
||||
return token
|
||||
? {
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
}
|
||||
: { headers: {} }
|
||||
return token ? { Authorization: 'Bearer ' + token } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -27,7 +27,9 @@
|
|||
"./linedrawings": "./src/linedrawings/index.mjs",
|
||||
"./pattern": "./src/pattern/index.mjs",
|
||||
"./xray": "./src/pattern-xray/index.mjs",
|
||||
"./components/Account": "./components/Account/index.mjs",
|
||||
"./components/Breadcrumbs": "./components/Breadcrumbs/index.mjs",
|
||||
"./components/Control": "./components/Control/index.mjs",
|
||||
"./components/DocusaurusPage": "./components/DocusaurusPage/index.mjs",
|
||||
"./components/Editor": "./components/Editor/index.mjs",
|
||||
"./components/Icon": "./components/Icon/index.mjs",
|
||||
|
@ -39,6 +41,7 @@
|
|||
"./components/Modal": "./components/Modal/index.mjs",
|
||||
"./components/Pattern": "./components/Pattern/index.mjs",
|
||||
"./components/Popout": "./components/Popout/index.mjs",
|
||||
"./components/Role": "./components/Role/index.mjs",
|
||||
"./components/SignIn": "./components/SignIn/index.mjs",
|
||||
"./components/Spinner": "./components/Spinner/index.mjs",
|
||||
"./components/Tab": "./components/Tab/index.mjs",
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { cloudflare as cloudflareConfig } from '@freesewing/config'
|
||||
|
||||
/*
|
||||
* VARIABLES
|
||||
*/
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue