1
0
Fork 0

wip: Updates to packages

This commit is contained in:
joostdecock 2024-12-14 11:34:23 +01:00
parent 1c81f46dee
commit 755c7395ba
45 changed files with 5268 additions and 22 deletions

View file

@ -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

View file

@ -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"

View 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',
}

View file

@ -35,7 +35,6 @@ const account = {
email: 3,
},
settings: {
language: 2,
units: 2,
newsletter: 2,
compare: 3,

View file

@ -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 }

View 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',
}

View 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)}&hellip;</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -0,0 +1,5 @@
import React from 'react'
import { AccountLinks } from './AccountLinks.mjs'
export { AccountLinks }

View 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>
)
}

View 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>
)
}

View 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

View file

@ -0,0 +1,3 @@
import { AccountLinks } from './links.mjs'
export const AccountOverview = ({ app }) => <AccountLinks app={app} />

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

File diff suppressed because it is too large Load diff

View 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>
)

View 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>
)
}

View 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>
)
}

View 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

View file

@ -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}>

View 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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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 } : {}
}
/**

View file

@ -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",

View file

@ -1,3 +1,5 @@
import { cloudflare as cloudflareConfig } from '@freesewing/config'
/*
* VARIABLES
*/