From 755c7395ba4131325b48ca609be7b618b3463feb Mon Sep 17 00:00:00 2001 From: joostdecock Date: Sat, 14 Dec 2024 11:34:23 +0100 Subject: [PATCH] wip: Updates to packages --- config/dependencies.yaml | 20 +- config/exceptions.yaml | 3 + packages/config/src/cloudflare.mjs | 6 + packages/config/src/control.mjs | 1 - packages/config/src/index.mjs | 6 +- packages/config/src/roles.mjs | 15 + .../react/components/Account/AccountLinks.mjs | 329 +++++ packages/react/components/Account/apikeys.mjs | 408 ++++++ packages/react/components/Account/bio.mjs | 98 ++ .../react/components/Account/bookmarks.mjs | 246 ++++ packages/react/components/Account/compare.mjs | 98 ++ packages/react/components/Account/consent.mjs | 116 ++ packages/react/components/Account/control.mjs | 105 ++ packages/react/components/Account/email.mjs | 76 + packages/react/components/Account/export.mjs | 49 + .../Account/force-account-check.mjs | 35 + packages/react/components/Account/github.mjs | 62 + packages/react/components/Account/img.mjs | 88 ++ .../react/components/Account/imperial.mjs | 90 ++ packages/react/components/Account/import.mjs | 101 ++ packages/react/components/Account/index.mjs | 5 + .../react/components/Account/language.mjs | 66 + packages/react/components/Account/mfa.mjs | 187 +++ .../react/components/Account/newsletter.mjs | 107 ++ .../react/components/Account/overview.mjs | 3 + .../react/components/Account/password.mjs | 66 + .../react/components/Account/patterns.mjs | 725 ++++++++++ .../react/components/Account/platform.mjs | 55 + packages/react/components/Account/profile.mjs | 54 + packages/react/components/Account/reload.mjs | 40 + packages/react/components/Account/remove.mjs | 42 + .../react/components/Account/restrict.mjs | 43 + packages/react/components/Account/role.mjs | 28 + packages/react/components/Account/sets.mjs | 1253 +++++++++++++++++ packages/react/components/Account/shared.mjs | 136 ++ packages/react/components/Account/status.mjs | 22 + .../react/components/Account/username.mjs | 109 ++ packages/react/components/Control/index.mjs | 13 + packages/react/components/Icon/index.mjs | 127 ++ packages/react/components/Role/index.mjs | 238 ++++ packages/react/components/SignIn/index.mjs | 5 +- packages/react/hooks/useAccount/index.mjs | 3 +- packages/react/hooks/useBackend/index.mjs | 6 +- packages/react/package.json | 3 + packages/utils/src/index.mjs | 2 + 45 files changed, 5268 insertions(+), 22 deletions(-) create mode 100644 packages/config/src/cloudflare.mjs create mode 100644 packages/config/src/roles.mjs create mode 100644 packages/react/components/Account/AccountLinks.mjs create mode 100644 packages/react/components/Account/apikeys.mjs create mode 100644 packages/react/components/Account/bio.mjs create mode 100644 packages/react/components/Account/bookmarks.mjs create mode 100644 packages/react/components/Account/compare.mjs create mode 100644 packages/react/components/Account/consent.mjs create mode 100644 packages/react/components/Account/control.mjs create mode 100644 packages/react/components/Account/email.mjs create mode 100644 packages/react/components/Account/export.mjs create mode 100644 packages/react/components/Account/force-account-check.mjs create mode 100644 packages/react/components/Account/github.mjs create mode 100644 packages/react/components/Account/img.mjs create mode 100644 packages/react/components/Account/imperial.mjs create mode 100644 packages/react/components/Account/import.mjs create mode 100644 packages/react/components/Account/index.mjs create mode 100644 packages/react/components/Account/language.mjs create mode 100644 packages/react/components/Account/mfa.mjs create mode 100644 packages/react/components/Account/newsletter.mjs create mode 100644 packages/react/components/Account/overview.mjs create mode 100644 packages/react/components/Account/password.mjs create mode 100644 packages/react/components/Account/patterns.mjs create mode 100644 packages/react/components/Account/platform.mjs create mode 100644 packages/react/components/Account/profile.mjs create mode 100644 packages/react/components/Account/reload.mjs create mode 100644 packages/react/components/Account/remove.mjs create mode 100644 packages/react/components/Account/restrict.mjs create mode 100644 packages/react/components/Account/role.mjs create mode 100644 packages/react/components/Account/sets.mjs create mode 100644 packages/react/components/Account/shared.mjs create mode 100644 packages/react/components/Account/status.mjs create mode 100644 packages/react/components/Account/username.mjs create mode 100644 packages/react/components/Control/index.mjs create mode 100644 packages/react/components/Role/index.mjs diff --git a/config/dependencies.yaml b/config/dependencies.yaml index 3eb6b426af2..b3416600abe 100644 --- a/config/dependencies.yaml +++ b/config/dependencies.yaml @@ -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 diff --git a/config/exceptions.yaml b/config/exceptions.yaml index d1fb03ff36b..d3e8751ab3a 100644 --- a/config/exceptions.yaml +++ b/config/exceptions.yaml @@ -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" diff --git a/packages/config/src/cloudflare.mjs b/packages/config/src/cloudflare.mjs new file mode 100644 index 00000000000..d74540b5f5d --- /dev/null +++ b/packages/config/src/cloudflare.mjs @@ -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', +} diff --git a/packages/config/src/control.mjs b/packages/config/src/control.mjs index f5879833c26..2dc45ca7b77 100644 --- a/packages/config/src/control.mjs +++ b/packages/config/src/control.mjs @@ -35,7 +35,6 @@ const account = { email: 3, }, settings: { - language: 2, units: 2, newsletter: 2, compare: 3, diff --git a/packages/config/src/index.mjs b/packages/config/src/index.mjs index 23e024f95eb..91ad054b8da 100644 --- a/packages/config/src/index.mjs +++ b/packages/config/src/index.mjs @@ -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 } diff --git a/packages/config/src/roles.mjs b/packages/config/src/roles.mjs new file mode 100644 index 00000000000..0f7f3e9d888 --- /dev/null +++ b/packages/config/src/roles.mjs @@ -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', +} diff --git a/packages/react/components/Account/AccountLinks.mjs b/packages/react/components/Account/AccountLinks.mjs new file mode 100644 index 00000000000..4d5489927aa --- /dev/null +++ b/packages/react/components/Account/AccountLinks.mjs @@ -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: , + sets: , + patterns: , + apikeys: , + username: , + email: , + bio: , + img: , + language: , + units: , + compare: , + consent: , + control: , + mfa: , + newsletter: , + password: , + github: , + instagram: , + mastodon: , + twitter: , + twitch: , + tiktok: , + website: , + reddit: , +} + +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 }) => ( + + {children} + +) + +const YesNo = ({ check }) => (check ? : ) + +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 ? {account.bio.slice(0, 15)}… : '', + img: ( + + ), + units: account.imperial ? 'Imperial' : 'Metric', + newsletter: , + compare: , + consent: , + control: , + github: account.data.githubUsername || account.data.githubEmail || , + password: account.passwordType === 'v3' ? : , + mfa: , + } + for (const social of Object.keys(controlConfig.account.fields.identities).filter( + (i) => i !== 'github' + )) + itemPreviews[social] = account.data[social] || ( + + ) + + return ( +
+
+
+

Your Data

+ {Object.keys(controlConfig.account.fields.data).map((item) => + controlConfig.flat[item] > control ? null : ( + +
+ {itemIcons[item]} + {titles[item] ? titles[item] : capitalize(item)} +
+
{itemPreviews[item]}
+
+ ) + )} +
+ + {control > 1 && ( +
+

About You

+ {Object.keys(controlConfig.account.fields.info).map((item) => + controlConfig.flat[item] > control ? null : ( + +
+ {itemIcons[item]} + {titles[item] ? titles[item] : capitalize(item)} +
+
{itemPreviews[item]}
+
+ ) + )} +
+
+ + Role +
+
{account.role}
+
+
+
+ + ID +
+
{account.id}
+
+
+ )} + +
+

Preferences

+ {Object.keys(controlConfig.account.fields.settings).map((item) => + controlConfig.flat[item] > control ? null : ( + +
+ {itemIcons[item]} + {titles[item] ? titles[item] : capitalize(item)} +
+
{itemPreviews[item]}
+
+ ) + )} +
+ + {control > 2 && ( +
+

Linked Identities

+ {Object.keys(controlConfig.account.fields.identities).map((item) => + controlConfig.flat[item] > control ? null : ( + +
+ {itemIcons[item]} + {titles[item] ? titles[item] : capitalize(item)} +
+
{itemPreviews[item]}
+
+ ) + )} +
+ )} + + {control > 1 && ( +
+

Security

+ {Object.keys(controlConfig.account.fields.security).map((item) => + controlConfig.flat[item] > control ? null : ( + +
+ {itemIcons[item]} + {titles[item] ? titles[item] : capitalize(item)} +
+
{itemPreviews[item]}
+
+ ) + )} +
+ )} + + {control > 1 && ( +
+

Actions

+ {control > 2 && ( + + + Import data + + )} + {control > 2 && ( + + + Export your data + + )} + {control > 2 && ( + + + Reload account data + + )} + {control > 3 && ( + + + Restrict processing of your data + + )} + + + Remove your account + +
+ )} +
+ +
+ {account.role === 'admin' && ( + + + Administration + + )} + {control > 1 && ( + + + {t('yourProfile')} + + )} + +
+
+ ) +} diff --git a/packages/react/components/Account/apikeys.mjs b/packages/react/components/Account/apikeys.mjs new file mode 100644 index 00000000000..f181bb201d7 --- /dev/null +++ b/packages/react/components/Account/apikeys.mjs @@ -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 ( + <> +
+ +
+ + {t('keyExpiresDesc')} + {shortDate(locale, expires)} + + + ) +} + +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 ( +
+ + + + +
+ ) +} + +export const Apikey = ({ apikey, setId }) => { + const { t } = useTranslation(ns) + const router = useRouter() + const { locale } = router + + return apikey ? ( +
+ {apikey.name} + {shortDate(locale, apikey.createdAt)} + {shortDate(locale, apikey.expiresAt)} + {apikey.key} +
+ +
+
+ ) : null +} + +const ShowKey = ({ apikey, t, clear }) => { + const router = useRouter() + const { locale } = router + + return ( +
+ + {t('keySecretWarning')} + + {apikey.name} + {shortDate(locale, apikey.createdAt)} + {shortDate(locale, apikey.expiresAt)} + + + + + + +
+ + +
+
+ ) +} + +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] = ( + + ) + } + + 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 ( +
+ {apikey ? ( + + ) : ( + <> + val.length > 0} + placeholder={'Alicia Key'} + /> + + + + ({ + val: l, + label: ( +
+ {t(`keyLevel${l}`)} + +
+ ), + }))} + current={level} + update={setLevel} + /> +
+ +
+ + )} +
+ ) +} + +// 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 ( +
+ +
+ ) +} + +// 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, + , + ]) + } + setSelected({}) + setRefresh(refresh + 1) + setLoadingStatus([true, 'nailedIt', true, true]) + } + + return ( +
+

+ + + {t('newApikey')} + +

+ + + + + + + + + + + + + {keys.map((apikey, i) => ( + + + + + + + + ))} + +
+ + {t('keyName')} + {t('keyLevel')} + + 🔐 + + {t('keyExpires')}{t('apiCalls')}
+ toggleSelect(apikey.id)} + /> + + + + {apikey.level} + + ({t(`keyLevel${apikey.level}`)}) + + + {shortDate(locale, apikey.expiresAt, false)} + + {formatNumber(apikey.calls)} +
+ + {account.control < 5 ? ( + +
{t('keyDocsTitle')}
+

{t('keyDocsMsg')}

+

+ + FreeSewing.dev + + +

+
+ ) : null} +
+ ) +} diff --git a/packages/react/components/Account/bio.mjs b/packages/react/components/Account/bio.mjs new file mode 100644 index 00000000000..510e13e7cbf --- /dev/null +++ b/packages/react/components/Account/bio.mjs @@ -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 }) => ( + +) + +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 ( +
+ } + labelBL={ + + + {t('mdSupport')} + + } + /> + + {!welcome && } + + {welcome ? ( + <> + + {welcomeSteps[account.control].length > 0 ? ( + <> + + + 6 / {welcomeSteps[account.control].length} + + + + ) : null} + + ) : null} +
+ ) +} diff --git a/packages/react/components/Account/bookmarks.mjs b/packages/react/components/Account/bookmarks.mjs new file mode 100644 index 00000000000..079d14a6af2 --- /dev/null +++ b/packages/react/components/Account/bookmarks.mjs @@ -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 ? ( +
+ {bookmark.title} + + {bookmark.url.length > 30 ? bookmark.url.slice(0, 30) + '...' : bookmark.url} + + {t(`${bookmark.type}Bookmark`)} +
+ + + {t('bookmarks')} + +
+
+ ) : 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] = ( + + ) + } + + // 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 ( +
+ val.length > 0} + placeholder={t('account')} + /> + val.length > 0} + placeholder={'https://freesewing.org/account'} + /> +
+ +
+
+ ) +} + +// 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, + , + ]) + } + 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 ( +
+

+ + + {t('newBookmark')} + +

+ {bookmarks.length > 0 ? ( + + ) : null} + {types.map((type) => + perType[type].length > 0 ? ( + +

{t(`${type}Bookmark`)}

+ + + + + + + + + + {bookmarks + .filter((bookmark) => bookmark.type === type) + .map((bookmark, i) => ( + + + + + + + + ))} + +
+ + {t('title')}{t('location')}
+ toggleSelect(bookmark.id)} + /> + + + + 30 + ? bookmark.url.slice(0, 30) + '...' + : bookmark.url + } + /> +
+
+ ) : null + )} + +
+ ) +} diff --git a/packages/react/components/Account/compare.mjs b/packages/react/components/Account/compare.mjs new file mode 100644 index 00000000000..49d664423a8 --- /dev/null +++ b/packages/react/components/Account/compare.mjs @@ -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 ( +
+ ({ + val, + label: ( +
+ {t(val === 'yes' ? 'compareYes' : 'compareNo')} + {val === 'yes' ? ( + + ) : ( + + )} +
+ ), + desc: t(val === 'yes' ? 'compareYesd' : 'compareNod'), + }))} + current={selection} + update={update} + docs={} + /> + {welcome ? ( + <> + + {welcomeSteps[account?.control].length > 0 ? ( + <> + + + 4 / {welcomeSteps[account?.control].length} + + + + ) : null} + + ) : ( + + )} +
+ ) +} diff --git a/packages/react/components/Account/consent.mjs b/packages/react/components/Account/consent.mjs new file mode 100644 index 00000000000..9a3576c8d8f --- /dev/null +++ b/packages/react/components/Account/consent.mjs @@ -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 }) => ( +
setter(value ? false : true)} + > +
+ setter(value ? false : true)} + /> + {label} +
+ {children} +
+) + +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 ( +
+ {title ?

{t('privacyMatters')}

: null} +

{t('compliant')}

+

{t('consentWhyAnswer')}

+
{t('accountQuestion')}
+ + {consent1 ? ( + + ) : ( + + )} + {consent1 ? ( + + ) : null} + {consent1 && !consent2 ? {t('openDataInfo')} : null} + {!consent1 && {t('noConsentNoAccount')}} + {consent1 ? ( + + ) : ( + + )} + +

+ + FreeSewing Privacy Notice + +

+
+ ) +} diff --git a/packages/react/components/Account/control.mjs b/packages/react/components/Account/control.mjs new file mode 100644 index 00000000000..d996dac0953 --- /dev/null +++ b/packages/react/components/Account/control.mjs @@ -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 ( +
+ ({ + val, + label: ( +
+ {t(`control${val}.t`)} + +
+ ), + desc: t(`control${val}.d`), + }))} + current={selection} + update={update} + docs={} + /> + {welcome ? ( + <> + + {welcomeSteps[selection].length > 1 ? ( + <> + + + 1 / {welcomeSteps[selection].length} + + + + ) : null} + + ) : noBack ? null : ( + + )} +
+ ) +} diff --git a/packages/react/components/Account/email.mjs b/packages/react/components/Account/email.mjs new file mode 100644 index 00000000000..e71603b1789 --- /dev/null +++ b/packages/react/components/Account/email.mjs @@ -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 ( +
+ {changed ? ( + +

{t('oneMoreThing')}

+

{t('emailChangeConfirmation')}

+
+ ) : ( + <> + valid} + docs={} + /> + + + )} + +
+ ) +} diff --git a/packages/react/components/Account/export.mjs b/packages/react/components/Account/export.mjs new file mode 100644 index 00000000000..97ff787c52a --- /dev/null +++ b/packages/react/components/Account/export.mjs @@ -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 ( +
+ {link ? ( + +
{t('exportDownload')}
+

+ +

+
+ ) : null} +

{t('exportMsg')}

+ + +
+ ) +} diff --git a/packages/react/components/Account/force-account-check.mjs b/packages/react/components/Account/force-account-check.mjs new file mode 100644 index 00000000000..4e5c0bd45d6 --- /dev/null +++ b/packages/react/components/Account/force-account-check.mjs @@ -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 +} diff --git a/packages/react/components/Account/github.mjs b/packages/react/components/Account/github.mjs new file mode 100644 index 00000000000..0d8c1a13107 --- /dev/null +++ b/packages/react/components/Account/github.mjs @@ -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 ( +
+

{t('githubTitle')}

+ val.length > 0} + placeholder={'joost@joost.at'} + docs={} + /> + val.length > 0} + placeholder={'joostdecock'} + docs={} + /> + + +
+ ) +} diff --git a/packages/react/components/Account/img.mjs b/packages/react/components/Account/img.mjs new file mode 100644 index 00000000000..8fd76fc42d1 --- /dev/null +++ b/packages/react/components/Account/img.mjs @@ -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 ( +
+ {!welcome || img !== false ? ( + img + ) : null} + val.length > 0} + docs={} + /> + {welcome ? ( + <> + + + {welcomeSteps[account.control].length > 0 ? ( + <> + + + 7 / {welcomeSteps[account.control].length} + + + + ) : null} + + ) : ( + <> + + + + )} +
+ ) +} diff --git a/packages/react/components/Account/imperial.mjs b/packages/react/components/Account/imperial.mjs new file mode 100644 index 00000000000..739c6ad74d3 --- /dev/null +++ b/packages/react/components/Account/imperial.mjs @@ -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 ( +
+ ({ + val, + label: ( +
+ {t(`${val}Units`)} + +
+ ), + desc: t(`${val}Unitsd`), + }))} + current={selection} + update={update} + docs={} + /> + {welcome ? ( + <> + + {welcomeSteps[account?.control].length > 0 ? ( + <> + + + 3 / {welcomeSteps[account?.control].length} + + + + ) : null} + + ) : ( + + )} +
+ ) +} diff --git a/packages/react/components/Account/import.mjs b/packages/react/components/Account/import.mjs new file mode 100644 index 00000000000..8892a26a56f --- /dev/null +++ b/packages/react/components/Account/import.mjs @@ -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 ( +
+

{t('account:importHere')}

+

{t('account:importSupported')}

+ +

{t('account:importSets')}

+ + +

{t('account:importSetTip1')}

+ +

{t('account:importSetTip2')}

+
+
+ ) +} diff --git a/packages/react/components/Account/index.mjs b/packages/react/components/Account/index.mjs new file mode 100644 index 00000000000..609aab482a4 --- /dev/null +++ b/packages/react/components/Account/index.mjs @@ -0,0 +1,5 @@ +import React from 'react' + +import { AccountLinks } from './AccountLinks.mjs' + +export { AccountLinks } diff --git a/packages/react/components/Account/language.mjs b/packages/react/components/Account/language.mjs new file mode 100644 index 00000000000..f55869462b3 --- /dev/null +++ b/packages/react/components/Account/language.mjs @@ -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 ( +
+ ({ + val, + label: ( +
+ + {t(`locales:${val}`)} + | + {t(`locales:${val}`, { lng: val })} + + +
+ ), + desc: t('languageTitle', { lng: val }), + }))} + current={language} + update={update} + docs={} + /> + +
+ ) +} diff --git a/packages/react/components/Account/mfa.mjs b/packages/react/components/Account/mfa.mjs new file mode 100644 index 00000000000..a4258b235fe --- /dev/null +++ b/packages/react/components/Account/mfa.mjs @@ -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 }) => ( + 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 ( +
+ {title ?

{titleText}

: null} + {enable ? ( + <> +
+
+
+

{enable.secret}

+ {t('mfaAdd')} + {t('confirmWithMfa')} + 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')} + /> + + + ) : null} + {disable ? ( +
+ +
{t('confirmWithPassword')}
+ true} + /> +
+ +
{t('confirmWithMfa')}
+ +
+ +
+ ) : null} + {scratchCodes ? ( + <> +

{t('account:mfaScratchCodes')}

+

{t('account:mfaScratchCodesMsg1')}

+

{t('account:mfaScratchCodesMsg2')}

+
+
+ {t('account:mfaScratchCodes')} + code + '\n').join('') + } + /> +
+
+              {scratchCodes.map((code) => code + '\n')}
+            
+
+ + ) : ( +
+ {account.mfaEnabled ? ( + disable ? null : ( + + ) + ) : enable ? null : ( +
+ + +
{t('mfaTipTitle')}
+

{t('mfaTipMsg')}

+
+
+ )} +
+ )} + {!welcome && } +
+ ) +} diff --git a/packages/react/components/Account/newsletter.mjs b/packages/react/components/Account/newsletter.mjs new file mode 100644 index 00000000000..bfd82d44a11 --- /dev/null +++ b/packages/react/components/Account/newsletter.mjs @@ -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 ( +
+ ({ + val, + label: ( +
+ {t(val === 'yes' ? 'newsletterYes' : 'noThanks')} + {val === 'yes' ? ( + + ) : ( + + )} +
+ ), + desc: t(val === 'yes' ? 'newsletterYesd' : 'newsletterNod'), + }))} + current={selection} + update={update} + docs={} + /> + {welcome ? ( + <> + + {welcomeSteps[account?.control].length > 0 ? ( + <> + + + 2 / {welcomeSteps[account?.control].length} + + + + ) : null} + + ) : bare ? null : ( + + )} + +

{t('newsletter:subscribePs')}

+

+ +

+
+
+ ) +} + +export default NewsletterSettings diff --git a/packages/react/components/Account/overview.mjs b/packages/react/components/Account/overview.mjs new file mode 100644 index 00000000000..045fd7ebe5c --- /dev/null +++ b/packages/react/components/Account/overview.mjs @@ -0,0 +1,3 @@ +import { AccountLinks } from './links.mjs' + +export const AccountOverview = ({ app }) => diff --git a/packages/react/components/Account/password.mjs b/packages/react/components/Account/password.mjs new file mode 100644 index 00000000000..d41958f10f1 --- /dev/null +++ b/packages/react/components/Account/password.mjs @@ -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 ( +
+ val.length > 0} + placeholder={t('passwordTitle')} + docs={} + /> + + {!welcome && } + {!account.mfaEnabled && ( + +
{t('mfaTipTitle')}
+

{t('mfaTipMsg')}

+

+ + {t('mfa')} + +

+
+ )} +
+ ) +} diff --git a/packages/react/components/Account/patterns.mjs b/packages/react/components/Account/patterns.mjs new file mode 100644 index 00000000000..d55b7cb0009 --- /dev/null +++ b/packages/react/components/Account/patterns.mjs @@ -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')} [#{id}] + , + true, + true, + ]) + } else setLoadingStatus([true, 'backendError', true, false]) + } + + if (!pattern) return

loading

+ + return ( + <> +
+
+ + + +
+
+ {pattern.name} + {pattern.id} + + + + + + + + + | + {shortDate(i18n.language, pattern.createdAt, false)} + + + + | + {shortDate(i18n.language, pattern.updatedAt, false)} + + + {pattern.public ? : } + + + + + + + {account.id ? ( + + ) : null} + + {t('clonePattern')} + + {isOwn ? ( + <> + +

{t('account:ownPublicPattern')}

+ + {t('account:privateView')} + +
+ + ) : null} +
+
+

{t('account:notes')}

+ {isOwn ? 'is own' : 'is not own'} + + + ) +} + +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 = ( + <> +
+
+ +
+
+ {account.control > 3 && pattern?.public ? ( + + ) : ( + + )} + + {pattern.userId === account.id && ( + <> + {edit ? ( + <> + + + + ) : ( + <> + + {t('updatePattern')} + + + {t('clonePattern')} + + + + )} + + )} +
+
+
+ + ) + + if (!edit) + return ( +
+ {heading} + {pattern.name} + {control >= controlLevels.sets.notes && ( + + + + )} + {control >= controlLevels.patterns.public && ( + <> + + {pattern.public ? : } + + {pattern.public && ( + + + + )} + + )} + {control >= controlLevels.sets.createdAt && ( + + + | + {shortDate(i18n.language, pattern.createdAt, false)} + + )} + {control >= controlLevels.patterns.updatedAt && ( + + + | + {shortDate(i18n.language, pattern.updatedAt, false)} + + )} + {control >= controlLevels.patterns.id && ( + {pattern.id} + )} + +

{t('account:ownPrivatePattern')}

+ + + {t('account:publicView')} + +
+
+ ) + + return ( +
+ {heading} +
    +
  • + +
  • + {account.control >= conf.account.sets.img ? ( +
  • + +
  • + ) : null} + {['public', 'units', 'notes'].map((id) => + account.control >= conf.account.sets[id] ? ( +
  • + +
  • + ) : null + )} +
+ + {/* Name is always shown */} + + val && val.length > 0} + docs={} + /> + + {/* img: Control level determines whether or not to show this */} + + {account.control >= conf.account.sets.img ? ( + val.length > 0} + docs={} + /> + ) : null} + + {/* public: Control level determines whether or not to show this */} + + {account.control >= conf.account.patterns.public ? ( + + {t('publicPattern')} + +
+ ), + desc: t('publicPatternDesc'), + }, + { + val: false, + label: ( +
+ {t('privatePattern')} + +
+ ), + desc: t('privatePatternDesc'), + }, + ]} + current={isPublic} + docs={} + /> + ) : null} + + {/* notes: Control level determines whether or not to show this */} + + {account.control >= conf.account.patterns.notes ? ( + } + /> + ) : null} + +
+ ) +} + +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 ( + + ) + + // Returns a link to an internal page + if (href && !useA) + return ( + + {inner} + + ) + + // Returns a link to an external page + if (href && useA) + return ( + + {inner} + + ) + + // Returns a div + return
{inner}
+} + +// Component to show the sort header in the pattern table +const SortButton = ({ field, label, order, orderAsc, updateOrder }) => ( + +) + +// 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, + , + ]) + } + 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 ( +
+

+ + + {t('patternNew')} + +

+ + + + + + + + + + + + + + + + {orderBy(patterns, order, orderAsc ? 'asc' : 'desc').map((pattern, i) => ( + + + + + + + + + + ))} + +
+ + + + {t('account:img')} + + + + + + + +
+ toggleSelect(pattern.id)} + /> + {pattern.id} + + + + + + + {shortDate(locale, pattern.createdAt, false)} + + {pattern.public ? : } +
+
+ +
+ ) +} diff --git a/packages/react/components/Account/platform.mjs b/packages/react/components/Account/platform.mjs new file mode 100644 index 00000000000..8c0609f0b06 --- /dev/null +++ b/packages/react/components/Account/platform.mjs @@ -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 ( +
+ val.length > 0} + placeholder={'joostdecock'} + docs={} + /> + + +
+ ) +} diff --git a/packages/react/components/Account/profile.mjs b/packages/react/components/Account/profile.mjs new file mode 100644 index 00000000000..c1679c75a01 --- /dev/null +++ b/packages/react/components/Account/profile.mjs @@ -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 }) => ( +
+ + app.setModal( + + + + ) + : null + } + className={app ? 'hover:cursor-zoom-in' : 'hover:cursor-zoom-out'} + /> +
+) + +export const AccountProfile = ({ app }) => { + const { account } = useAccount() + + if (!account) return null + + return ( +
+
+ + {!account.patron ? ( + + + + ) : null} +
+

{account.username}

+
+
+
+
+ +
+
+ ) +} diff --git a/packages/react/components/Account/reload.mjs b/packages/react/components/Account/reload.mjs new file mode 100644 index 00000000000..d82b43e7188 --- /dev/null +++ b/packages/react/components/Account/reload.mjs @@ -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 ( +
+ {title ?

{t('reloadMsg1')}

: null} +

{t('reloadMsg2')}

+ + +
+ ) +} diff --git a/packages/react/components/Account/remove.mjs b/packages/react/components/Account/remove.mjs new file mode 100644 index 00000000000..b22536c21db --- /dev/null +++ b/packages/react/components/Account/remove.mjs @@ -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 ( +
+ +

{t('noWayBack')}

+ +
+ +
+ ) +} diff --git a/packages/react/components/Account/restrict.mjs b/packages/react/components/Account/restrict.mjs new file mode 100644 index 00000000000..255b5d64aee --- /dev/null +++ b/packages/react/components/Account/restrict.mjs @@ -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 ( +
+ +
{t('proceedWithCaution')}
+

{t('restrictWarning')}

+ +
+ +
+ ) +} diff --git a/packages/react/components/Account/role.mjs b/packages/react/components/Account/role.mjs new file mode 100644 index 00000000000..e0391325c08 --- /dev/null +++ b/packages/react/components/Account/role.mjs @@ -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 ( + + + role + + + {role} + + + ) +} diff --git a/packages/react/components/Account/sets.mjs b/packages/react/components/Account/sets.mjs new file mode 100644 index 00000000000..8a9463e6b5e --- /dev/null +++ b/packages/react/components/Account/sets.mjs @@ -0,0 +1,1253 @@ +// __SDEFILE__ - This file is a dependency for the stand-alone environment +// Dependencies +import { measurements } from 'config/measurements.mjs' +import { measurements as designMeasurements } from 'shared/prebuild/data/design-measurements.mjs' +import { freeSewingConfig as conf, controlLevels } from 'shared/config/freesewing.config.mjs' +import { isDegreeMeasurement } from 'config/measurements.mjs' +import { + shortDate, + cloudflareImageUrl, + formatMm, + hasRequiredMeasurements, + capitalize, + horFlexClasses, +} from 'shared/utils.mjs' +// Hooks +import { useState, useEffect, useContext } from 'react' +import { useTranslation } from 'next-i18next' +import { useAccount } from 'shared/hooks/use-account.mjs' +import { useBackend } from 'shared/hooks/use-backend.mjs' +import { useRouter } from 'next/router' +// Context +import { LoadingStatusContext } from 'shared/context/loading-status-context.mjs' +import { ModalContext } from 'shared/context/modal-context.mjs' +// Components +import { Popout } from 'shared/components/popout/index.mjs' +import { BackToAccountButton } from './shared.mjs' +import { AnchorLink, PageLink, Link } from 'shared/components/link.mjs' +import { Json } from 'shared/components/json.mjs' +import { Yaml } from 'shared/components/yaml.mjs' +import { + OkIcon, + NoIcon, + TrashIcon, + EditIcon, + UploadIcon, + ResetIcon, + PlusIcon, + WarningIcon, + CameraIcon, + CsetIcon, + BoolYesIcon, + BoolNoIcon, + CloneIcon, +} from 'shared/components/icons.mjs' +import { ModalWrapper } from 'shared/components/wrappers/modal.mjs' +import { Mdx } from 'shared/components/mdx/dynamic.mjs' +import Timeago from 'react-timeago' +import { DisplayRow } from './shared.mjs' +import { + StringInput, + ToggleInput, + PassiveImageInput, + ListInput, + MarkdownInput, + MeasieInput, + DesignDropdown, + ns as inputNs, +} from 'shared/components/inputs.mjs' +import { BookmarkButton } from 'shared/components/bookmarks.mjs' +import { DynamicMdx } from 'shared/components/mdx/dynamic.mjs' + +export const ns = [inputNs, 'account', 'patterns', 'status', 'measurements', 'sets'] + +export const NewSet = () => { + // Hooks + const { setLoadingStatus } = useContext(LoadingStatusContext) + const backend = useBackend() + const { t } = useTranslation(ns) + const router = useRouter() + const { account } = useAccount() + + // State + const [name, setName] = useState('') + + // Use account setting for imperial + const imperial = account.imperial + + // Helper method to create a new set + const createSet = async () => { + setLoadingStatus([true, 'processingUpdate']) + const result = await backend.createSet({ name, imperial }) + if (result.success) { + setLoadingStatus([true, t('nailedIt'), true, true]) + router.push(`/account/set?id=${result.data.set.id}`) + } else setLoadingStatus([true, 'backendError', true, false]) + } + + return ( +
+
{t('name')}
+

{t('setNameDesc')}

+ setName(evt.target.value)} + className="input w-full input-bordered flex flex-row" + type="text" + placeholder={'Georg Cantor'} + /> +
+ +
+
+ ) +} + +export const MeasieVal = ({ val, m, imperial }) => + isDegreeMeasurement(m) ? ( + {val}° + ) : ( + + ) + +export const MsetCard = ({ + set, + onClick = false, + href = false, + useA = false, + design = false, + language = false, + size = 'lg', +}) => { + const sizes = { + lg: 96, + md: 52, + sm: 36, + } + const s = sizes[size] + const { t } = useTranslation(ns) + + const wrapperProps = { + className: `bg-base-300 aspect-square h-${s} w-${s} mb-2 + mx-auto flex flex-col items-start text-center justify-between rounded-none md:rounded shadow`, + style: { + backgroundImage: `url(${cloudflareImageUrl({ type: 'w500', id: set.img })})`, + backgroundSize: 'cover', + backgroundRepeat: 'no-repeat', + backgroundPosition: '50%', + }, + } + if (!set.img || set.img === 'default-avatar') + wrapperProps.style.backgroundPosition = 'bottom right' + + let icon = + let missingMeasies = '' + let linebreak = '' + const maxLength = 75 + if (design) { + const [hasMeasies, missing] = hasRequiredMeasurements( + designMeasurements[design], + set.measies, + true + ) + const iconClasses = 'w-8 h-8 p-1 rounded-full -mt-2 -ml-2 shadow' + icon = hasMeasies ? ( + + ) : ( + + ) + if (missing.length > 0) { + const translated = missing.map((m) => { + return t(m) + }) + let missingString = t('missing') + ': ' + translated.join(', ') + if (missingString.length > maxLength) { + const lastSpace = missingString.lastIndexOf(', ', maxLength) + missingString = missingString.substring(0, lastSpace) + ', ' + t('andMore') + '...' + } + const measieClasses = 'font-normal text-xs' + missingMeasies = {missingString} + linebreak =
+ } + } + + const inner = ( + <> + {icon} + + {language ? set[`name${capitalize(language)}`] : set.name} + {linebreak} + {missingMeasies} + + + ) + + // Is it a button with an onClick handler? + if (onClick) + return ( + + ) + + // Returns a link to an internal page + if (href && !useA) + return ( + + {inner} + + ) + + // Returns a link to an external page + if (href && useA) + return ( + + {inner} + + ) + + // Returns a div + return
{inner}
+} + +export const Mset = ({ id, publicOnly = false }) => { + // Hooks + const { account, control } = useAccount() + const { setLoadingStatus } = useContext(LoadingStatusContext) + const backend = useBackend() + const { t, i18n } = useTranslation(ns) + + // Context + const { setModal } = useContext(ModalContext) + + const [filter, setFilter] = useState(false) + const [edit, setEdit] = useState(false) + const [suggest, setSuggest] = useState(false) + const [mset, setMset] = useState() + // Set fields for editing + const [name, setName] = useState(mset?.name) + const [image, setImage] = useState(mset?.image) + const [isPublic, setIsPublic] = useState(mset?.public ? true : false) + const [imperial, setImperial] = useState(mset?.imperial ? true : false) + const [notes, setNotes] = useState(mset?.notes || '') + const [measies, setMeasies] = useState({}) + const [displayAsMetric, setDisplayAsMetric] = useState(mset?.imperial ? false : true) + + // Effect + useEffect(() => { + const getSet = async () => { + setLoadingStatus([true, t('backendLoadingStarted')]) + const result = await backend.getSet(id) + if (result.success) { + setMset(result.data.set) + setName(result.data.set.name) + setImage(result.data.set.image) + setIsPublic(result.data.set.public ? true : false) + setImperial(result.data.set.imperial ? true : false) + setNotes(result.data.set.notes) + setMeasies(result.data.set.measies) + setLoadingStatus([true, 'backendLoadingCompleted', true, true]) + } else setLoadingStatus([true, 'backendError', true, false]) + } + const getPublicSet = async () => { + setLoadingStatus([true, t('backendLoadingStarted')]) + const result = await backend.getPublicSet(id) + if (result.success) { + setMset({ + ...result.data, + public: true, + measies: result.data.measurements, + }) + setName(result.data.name) + setImage(result.data.image) + setIsPublic(result.data.public ? true : false) + setImperial(result.data.imperial ? true : false) + setNotes(result.data.notes) + setMeasies(result.data.measurements) + setLoadingStatus([true, 'backendLoadingCompleted', true, true]) + } else setLoadingStatus([true, 'backendError', true, false]) + } + if (id) { + if (publicOnly) getPublicSet() + else getSet() + } + }, [id, publicOnly]) + + const filterMeasurements = () => { + if (!filter) return measurements.map((m) => t(`measurements:${m}`) + `|${m}`).sort() + else return designMeasurements[filter].map((m) => t(`measurements:${m}`) + `|${m}`).sort() + } + + if (!id || !mset) return null + + const updateMeasies = (m, val) => { + const newMeasies = { ...measies } + newMeasies[m] = val + setMeasies(newMeasies) + } + + const save = async () => { + setLoadingStatus([true, 'gatheringInfo']) + // Compile data + const data = { measies: {} } + if (name || name !== mset.name) data.name = name + if (image || image !== mset.image) data.img = image + if ([true, false].includes(isPublic) && isPublic !== mset.public) data.public = isPublic + if ([true, false].includes(imperial) && imperial !== mset.imperial) data.imperial = imperial + if (notes || notes !== mset.notes) data.notes = notes + // Add measurements + for (const m of measurements) { + if (measies[m] || measies[m] !== mset.measies[m]) data.measies[m] = measies[m] + } + setLoadingStatus([true, 'savingSet']) + const result = await backend.updateSet(mset.id, data) + if (result.success) { + setMset(result.data.set) + setEdit(false) + setLoadingStatus([true, 'nailedIt', true, true]) + } else setLoadingStatus([true, 'backendError', true, false]) + } + + const togglePublic = async () => { + setLoadingStatus([true, 'gatheringInfo']) + const result = await backend.updateSet(mset.id, { public: !mset.public }) + if (result.success) { + setMset(result.data.set) + setLoadingStatus([true, 'nailedIt', true, true]) + } else setLoadingStatus([true, 'backendError', true, false]) + } + + const importSet = async () => { + setLoadingStatus([true, t('account.importing')]) + // Compile data + const data = { + ...mset, + userId: account.id, + measies: { ...mset.measies }, + } + delete data.img + const result = await backend.createSet(data) + if (result.success) { + setMset(result.data.set) + setEdit(false) + setLoadingStatus([true, 'nailedIt', true, true]) + } else setLoadingStatus([true, 'backendError', true, false]) + } + + const docs = {} + for (const option of ['name', 'units', 'public', 'notes', 'image']) { + docs[option] = + } + + const heading = ( + <> +
+
+ +
+
+ {account.control > 2 && mset.public && mset.userId !== account.id ? ( + + ) : ( + + )} + {account.control > 3 && mset.userId === account.id ? ( +
+ + +
+ ) : ( + + )} + {account.id && account.control > 2 && mset.public && mset.userId !== account.id ? ( + + ) : null} + {account.control > 2 ? ( + + ) : null} + + {!publicOnly && ( + <> + {account.control > 2 ? ( + + ) : null} + {edit ? ( + <> + + + + ) : ( + + )} + + )} + {account.control > 2 && mset.userId === account.id ? ( + + ) : null} +
+
+
+ + ) + + if (suggest) + return ( +
+ {heading} + +
+ ) + + if (!edit) + return ( +
+ {heading} + +

{t('data')}

+ {mset.name} + + {mset.imperial ? t('imperialUnits') : t('metricUnits')} + + {control >= controlLevels.sets.notes && ( + + + + )} + {control >= controlLevels.sets.public && ( + <> + {mset.userId === account.id && ( + +
+ {mset.public ? ( + + ) : ( + + )} + +
+
+ )} + {mset.public && ( + + + + )} + + )} + {control >= controlLevels.sets.createdAt && ( + + + | + {shortDate(i18n.language, mset.createdAt, false)} + + )} + {control >= controlLevels.sets.updatedAt && ( + + + | + {shortDate(i18n.language, mset.updatedAt, false)} + + )} + {control >= controlLevels.sets.id && {mset.id}} + + {Object.keys(mset.measies).length > 0 && ( + <> +

{t('measies')}

+ setDisplayAsMetric(!displayAsMetric)} + current={displayAsMetric} + /> + {Object.entries(mset.measies).map(([m, val]) => + val > 0 ? ( + } + key={m} + > + {t(m)} + + ) : null + )} + + )} +
+ ) + + return ( +
+ {heading} +
    + {['measies', 'data'].map((s) => ( +
  • + +
  • + ))} +
      +
    • + +
    • + {account.control >= conf.account.sets.img ? ( +
    • + +
    • + ) : null} + {['public', 'units', 'notes'].map((id) => + account.control >= conf.account.sets[id] ? ( +
    • + +
    • + ) : null + )} +
    +
+ +

{t('measies')}

+
+ {t('noFilter')}} + /> +
+ {filterMeasurements().map((mplus) => { + const [translated, m] = mplus.split('|') + + return ( + + } + /> + ) + })} + +

{t('data')}

+ + {/* Name is always shown */} + + val && val.length > 0} + docs={docs.name} + /> + + {/* img: Control level determines whether or not to show this */} + + {account.control >= conf.account.sets.img ? ( + val.length > 0} + docs={docs.image} + /> + ) : null} + + {/* public: Control level determines whether or not to show this */} + + {account.control >= conf.account.sets.public ? ( + + {t('publicSet')} + +
+ ), + desc: t('publicSetDesc'), + }, + { + val: false, + label: ( +
+ {t('privateSet')} + +
+ ), + desc: t('privateSetDesc'), + }, + ]} + current={isPublic} + docs={docs.public} + /> + ) : null} + + {/* units: Control level determines whether or not to show this */} + + {account.control >= conf.account.sets.units ? ( + <> + + {t('metricUnits')} + cm + + ), + desc: t('metricUnitsd'), + }, + { + val: true, + label: ( +
+ {t('imperialUnits')} + +
+ ), + desc: t('imperialUnitsd'), + }, + ]} + current={imperial} + docs={docs.units} + /> + {t('unitsMustSave')} + + ) : null} + + {/* notes: Control level determines whether or not to show this */} + + {account.control >= conf.account.sets.notes ? ( + + ) : null} + + + ) +} + +// Component for the account/sets page +export const Sets = () => { + // Hooks + const { control } = useAccount() + const backend = useBackend() + const { t } = useTranslation(ns) + const { setLoadingStatus, LoadingProgress } = useContext(LoadingStatusContext) + + // State + const [sets, setSets] = useState([]) + const [selected, setSelected] = useState({}) + const [refresh, setRefresh] = useState(0) + + // Effects + useEffect(() => { + const getSets = async () => { + const result = await backend.getSets() + if (result.success) setSets(result.data.sets) + } + getSets() + }, [refresh]) + + // Helper var to see how many are selected + const selCount = Object.keys(selected).length + + // 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 === sets.length) setSelected({}) + else { + const newSelected = {} + for (const set of sets) newSelected[set.id] = 1 + setSelected(newSelected) + } + } + + // Helper to delete one or more measurements sets + const removeSelectedSets = async () => { + let i = 0 + for (const id in selected) { + i++ + await backend.removeSet(id) + setLoadingStatus([ + true, + , + ]) + } + setSelected({}) + setRefresh(refresh + 1) + setLoadingStatus([true, 'nailedIt', true, true]) + } + + return ( +
+ {sets.length > 0 ? ( + <> +

+ + + {t('account:importSets')} + + + + {t('newSet')} + +

+
+ + +
+ + ) : ( + + + {t('newSet')} + + )} +
+ {sets.map((set, i) => ( +
+ +
+ +
+
+ ))} +
+ +
+ ) +} + +export const SetCard = ({ + set, + requiredMeasies = [], + href = false, + onClick = false, + useA = false, +}) => { + // Hooks + const { t } = useTranslation(['sets']) + + const [hasMeasies] = hasRequiredMeasurements(requiredMeasies, set.measies, true) + + 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', + style: { + backgroundImage: `url(${cloudflareImageUrl({ type: 'w1000', id: set.img })})`, + backgroundSize: 'cover', + backgroundRepeat: 'no-repeat', + backgroundPosition: '50%', + }, + } + if (set.img === 'default-avatar') wrapperProps.style.backgroundPosition = 'bottom right' + + const inner = hasMeasies ? null : ( +
+ + {t('setLacksMeasiesForDesign')} +
+ ) + + // Is it a button with an onClick handler? + if (onClick) + return ( + + ) + + // Returns a link to an internal page + if (href && !useA) + return ( + + {inner} + + ) + + // Returns a link to an external page + if (href && useA) + return ( + + {inner} + + ) + + // Returns a div + return
{inner}
+} + +export const MsetButton = (props) => +export const MsetLink = (props) => +export const MsetA = (props) => + +export const UserSetPicker = ({ + design, + t, + href, + clickHandler, + missingClickHandler, + size = 'lg', +}) => { + // Hooks + const backend = useBackend() + const { control } = useAccount() + + // State + const [sets, setSets] = useState({}) + + // Effects + useEffect(() => { + const getSets = async () => { + const result = await backend.getSets() + if (result.success) { + const all = {} + for (const set of result.data.sets) all[set.id] = set + setSets(all) + } + } + getSets() + }, [backend]) + + let hasSets = false + const okSets = [] + const lackingSets = [] + if (Object.keys(sets).length > 0) { + hasSets = true + for (const setId in sets) { + const [hasMeasies] = hasRequiredMeasurements( + designMeasurements[design], + sets[setId].measies, + true + ) + if (hasMeasies) okSets.push(sets[setId]) + else lackingSets.push(sets[setId]) + } + } + + if (!hasSets) + return ( +
+ +
{t('account:noOwnSets')}
+

{t('account:pleaseMtm')}

+

{t('account:noOwnSetsMsg')}

+

+ + + {t('account:newSet')} + +

+
+
+ ) + + return ( + <> + {okSets.length > 0 && ( +
+ {okSets.map((set) => ( + + ))} +
+ )} + {lackingSets.length > 0 && ( +
+ + {t('account:someSetsLacking')} + +
+ {lackingSets.map((set) => ( + + ))} +
+
+ )} + + ) +} + +export const BookmarkedSetPicker = ({ design, clickHandler, t, size, href }) => { + // Hooks + const { control } = useAccount() + const backend = useBackend() + + // State + const [sets, setSets] = useState({}) + + // Effects + useEffect(() => { + const getBookmarks = async () => { + const result = await backend.getBookmarks() + const loadedSets = {} + if (result.success) { + for (const bookmark of result.data.bookmarks.filter( + (bookmark) => bookmark.type === 'set' + )) { + let set + try { + set = await backend.getSet(bookmark.url.slice(6)) + if (set.success) { + const [hasMeasies] = hasRequiredMeasurements( + designMeasurements[design], + set.data.set.measies, + true + ) + loadedSets[set.data.set.id] = { ...set.data.set, hasMeasies } + } + } catch (err) { + console.log(err) + } + } + } + setSets(loadedSets) + } + getBookmarks() + }, []) + + const okSets = Object.values(sets).filter((set) => set.hasMeasies) + const lackingSets = Object.values(sets).filter((set) => !set.hasMeasies) + + return ( + <> + {okSets.length > 0 && ( +
+ {okSets.map((set) => ( + + ))} +
+ )} + {lackingSets.length > 0 && ( +
+ + {t('account:someSetsLacking')} + +
+ {lackingSets.map((set) => ( + + ))} +
+
+ )} + + ) +} + +const SuggestCset = ({ mset, backend, setLoadingStatus, t }) => { + // State + const [height, setHeight] = useState('') + const [img, setImg] = useState('') + const [name, setName] = useState('') + const [notes, setNotes] = useState('') + const [submission, setSubmission] = useState(false) + + // Method to submit the form + const suggestSet = async () => { + setLoadingStatus([true, 'status:contactingBackend']) + const result = await backend.suggestCset({ set: mset.id, height, img, name, notes }) + if (result.success && result.data.submission) { + setSubmission(result.data.submission) + setLoadingStatus([true, 'status:nailedIt', true, true]) + } else setLoadingStatus([true, 'backendError', true, false]) + } + + const missing = [] + for (const m of measurements) { + if (typeof mset.measies[m] === 'undefined') missing.push(m) + } + + if (submission) { + const url = `/curate/sets/suggested/${submission.id}` + + return ( + <> +

{t('account:thankYouVeryMuch')}

+

{t('account:csetSuggestedMsg')}

+

+ {t('account:itIsAvailableAt')}: +

+ + ) + } + + return ( + <> +

{t('account:suggestCset')}

+

+ {missing.length > 0 ? : } + {t('account:measurements')} +

+ {missing.length > 0 ? ( + <> +

{t('account:csetAllMeasies')}

+

{t('account:csetMissing')}:

+
    + {missing.map((m) => ( +
  • {t(`measurements:${m}`)}
  • + ))} +
+ + ) : ( +

{t('account:allMeasiesAvailable')}

+ )} +

+ {name.length > 1 ? : } + {t('account:name')} +

+

{t('account:csetNameMsg')}

+ val.length > 1} + /> +

+ {height.length > 1 ? : } + {t('measurements:height')} +

+

{t('account:csetHeightMsg1')}

+ val.length > 1} + /> +

+ {img.length > 0 ? : } + {t('account:img')} +

+

+ {t('account:csetImgMsg')}:{' '} + {t('account:docs')} +

+ val.length > 1} + /> +

+ + {t('account:notes')} +

+

{t('account:csetNotesMsg')}

+ + {t('account:mdSupport')} + + true} + /> + + + ) +} diff --git a/packages/react/components/Account/shared.mjs b/packages/react/components/Account/shared.mjs new file mode 100644 index 00000000000..fe5036a7a50 --- /dev/null +++ b/packages/react/components/Account/shared.mjs @@ -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' }) => ( + + {nr} + +) + +export const BackToAccountButton = ({ loading = false }) => { + const { t } = useTranslation(['account']) + + return ( + + + {loading ? : } + {t('yourAccount')} + + + ) +} + +export const Choice = ({ + val, + update, + current, + children, + bool = false, + boolChoices = { + yes: , + no: , + }, +}) => { + const active = val === current + + return ( + + ) +} + +export const DoneIcon = ({ href }) => ( + + + +) +export const TodoIcon = ({ href }) => ( + + + +) + +const TopicIcon = (props) => { + const Icon = + props.href === '' || props.href === 'control' + ? ControlIcon + : icons[props.href] + ? icons[props.href] + : CogIcon + + return +} + +const DoingIcon = ({ href }) => + +export const Icons = ({ done = [], todo = [], current = '' }) => ( +
+ {done.map((href) => ( + + ))} + + {todo.map((href) => ( + + ))} +
+) + +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' }) => ( +
+
+ {title} +
+
{children}
+
+) diff --git a/packages/react/components/Account/status.mjs b/packages/react/components/Account/status.mjs new file mode 100644 index 00000000000..cfc9d623d16 --- /dev/null +++ b/packages/react/components/Account/status.mjs @@ -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 ( + + + status + + + {name} + + + ) +} diff --git a/packages/react/components/Account/username.mjs b/packages/react/components/Account/username.mjs new file mode 100644 index 00000000000..2bbd2376bcd --- /dev/null +++ b/packages/react/components/Account/username.mjs @@ -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 ( +
+ available} + placeholder={'Sorcha Ni Dhubghaill'} + labelBL={ + + {available ? ( + <> + {t('usernameAvailable')} + + ) : ( + <> + {t('usernameNotAvailable')} + + )} + + } + docs={} + /> + + + {welcome ? ( + <> + + {welcomeSteps[account.control].length > 0 ? ( + <> + + + 5 / {welcomeSteps[account.control].length} + + + + ) : null} + + ) : ( + + )} +
+ ) +} diff --git a/packages/react/components/Control/index.mjs b/packages/react/components/Control/index.mjs new file mode 100644 index 00000000000..58a2432e430 --- /dev/null +++ b/packages/react/components/Control/index.mjs @@ -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 ? ( +
+ {scores.map((score) => ( + = score ? true : false} className="w-6 h-6 -ml-1" key={score} /> + ))} +
+ ) : null diff --git a/packages/react/components/Icon/index.mjs b/packages/react/components/Icon/index.mjs index 6f92f8a5d4f..f4f9da1b615 100644 --- a/packages/react/components/Icon/index.mjs +++ b/packages/react/components/Icon/index.mjs @@ -71,6 +71,13 @@ export const BookmarkIcon = (props) => ( ) +// Looks like a circle +export const BulletIcon = (props) => ( + + + +) + // Looks lik a speech bubble export const ChatIcon = (props) => ( @@ -92,6 +99,20 @@ export const CloseIcon = (props) => ( ) +// FIXME +export const CompareIcon = (props) => ( + + + +) + +// Looks like scales of justice +export const ControlIcon = (props) => ( + + + +) + // Looks like a museum building export const CuratedMeasurementsSetIcon = (props) => ( @@ -132,6 +153,13 @@ export const DownIcon = (props) => ( ) +// Looks like a cloud with an arrow pointing down from it +export const DownloadIcon = (props) => ( + + + +) + // Looks like a pencil export const EditIcon = (props) => ( @@ -172,6 +200,13 @@ export const FailureIcon = ({ size = 6 }) => ( ) +// Looks like a fingerprint +export const FingerprintIcon = (props) => ( + + + +) + // Looks lik a flag export const FlagIcon = (props) => ( @@ -230,6 +265,13 @@ export const IncludeIcon = (props) => ( ) +// Looks like the Instagram logo +export const InstagramIcon = (props) => ( + + + +) + // Looks like a key export const KeyIcon = (props) => ( @@ -251,6 +293,13 @@ export const LeftIcon = (props) => ( ) +// Looks like a chain link +export const LinkIcon = (props) => ( + + + +) + // Looks like a bullet list export const ListIcon = (props) => ( @@ -272,6 +321,13 @@ export const MarginIcon = (props) => ( ) +// Looks like the Mastodon logo +export const MastodonIcon = (props) => ( + + + +) + // Looks like a tape measure export const MeasurementsIcon = (props) => ( @@ -293,6 +349,13 @@ export const MenuIcon = (props) => ( ) +// Looks like a newspaper +export const NewsletterIcon = (props) => ( + + + +) + // Looks like a X export const NoIcon = (props) => ( @@ -325,6 +388,13 @@ export const PaperlessIcon = (props) => ( ) +// Looks like a page +export const PatternIcon = (props) => ( + + + +) + // Looks like a + export const PlusIcon = (props) => ( @@ -339,6 +409,27 @@ export const PrintIcon = (props) => ( ) +// FIXME +export const PrivacyIcon = (props) => ( + + + +) + +// Looks like the Reddit alian +export const RedditIcon = (props) => ( + + + +) + +// FIXME +export const ReloadIcon = (props) => ( + + + +) + // Looks like a single rewind arrow export const ResetIcon = (props) => ( @@ -413,6 +504,28 @@ export const SettingsIcon = (props) => ( ) +// Looks like a shield +export const ShieldIcon = (props) => ( + + + +) + +// Looks like a picture camera +export const ShowcaseIcon = (props) => ( + + + + +) + +// Looks like an exit door +export const SignoutIcon = (props) => ( + + + +) + // Looks like a spinning circle export const SpinnerIcon = (props) => ( ( ) +// Looks like the TikTok t +export const TikTokIcon = (props) => ( + + + +) + // Looks like a light bulb export const TipIcon = (props) => ( @@ -455,6 +575,13 @@ export const TrashIcon = (props) => ( ) +// Looks like the twitch logo +export const TwitchIcon = (props) => ( + + + +) + // Looks like a desktop screen export const UiIcon = (props) => ( diff --git a/packages/react/components/Role/index.mjs b/packages/react/components/Role/index.mjs new file mode 100644 index 00000000000..cf4b2fdd0d6 --- /dev/null +++ b/packages/react/components/Role/index.mjs @@ -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 }) => ( +
{children}
+) + +const ContactSupport = ({ Link = false }) => { + if (!Link) Link = DefaultLink + + return ( +
+ + {t('contactSupport')} + +
+ ) +} + +const AuthRequired = ({ Link, banner }) => { + if (!Link) Link = DefaultLink + + return ( + + {banner} +

Authentication Required

+

This functionality requires a FreeSewing account

+
+ + + Sign Up + + + + Sign In + +
+
+ ) +} + +const AccountInactive = ({ Link, banner }) => { + if (!Link) Link = DefaultLink + + return ( + + {banner} +

{t('accountInactive')}

+

{t('accountInactiveMsg')}

+

{t('signupAgain')}

+
+ + {t('signUp')} + +
+
+ ) +} + +const AccountDisabled = ({ banner }) => ( + + {banner} +

{t('accountDisabled')}

+

{t('accountDisabledMsg')}

+ +
+) + +const AccountProhibited = ({ banner }) => ( + + {banner} +

{t('accountProhibited')}

+

{t('accountProhibitedMsg')}

+ +
+) + +const AccountStatusUnknown = ({ t, banner }) => ( + + {banner} +

{t('statusUnknown')}

+

{t('statusUnknownMsg')}

+ +
+) + +const RoleLacking = ({ t, requiredRole, role, banner }) => ( + + {banner} +

{t('roleLacking')}

+

+ + +) + +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 ( + +

+ {banner} + +
+
+ ) +} + +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 ( + <> +

not ready

+ + + ) + + const banner = impersonating ? ( +
+ + Hi {impersonating.admin}, you are currently impersonating {impersonating.user} + + +
+ ) : null + + const childProps = { t, banner } + + if (!token || !account.username) return + if (error) { + if (error === 'accountInactive') return + if (error === 'accountDisabled') return + if (error === 'accountBlocked') return + if (error === 'consentLacking') return + return + } + + if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) { + return + } + + return children +} diff --git a/packages/react/components/SignIn/index.mjs b/packages/react/components/SignIn/index.mjs index d5557a18de7..26e4d2c4dfc 100644 --- a/packages/react/components/SignIn/index.mjs +++ b/packages/react/components/SignIn/index.mjs @@ -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 diff --git a/packages/react/hooks/useAccount/index.mjs b/packages/react/hooks/useAccount/index.mjs index 40364cb8e23..c43ce43af9d 100644 --- a/packages/react/hooks/useAccount/index.mjs +++ b/packages/react/hooks/useAccount/index.mjs @@ -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) } diff --git a/packages/react/hooks/useBackend/index.mjs b/packages/react/hooks/useBackend/index.mjs index 9de6c036e82..974a2218e37 100644 --- a/packages/react/hooks/useBackend/index.mjs +++ b/packages/react/hooks/useBackend/index.mjs @@ -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 } : {} } /** diff --git a/packages/react/package.json b/packages/react/package.json index 8273a426d1a..d3b253faeea 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/utils/src/index.mjs b/packages/utils/src/index.mjs index e5ead16292f..7fec0c50c36 100644 --- a/packages/utils/src/index.mjs +++ b/packages/utils/src/index.mjs @@ -1,3 +1,5 @@ +import { cloudflare as cloudflareConfig } from '@freesewing/config' + /* * VARIABLES */