diff --git a/config/dependencies.yaml b/config/dependencies.yaml index ca46b770689..04ee761ae09 100644 --- a/config/dependencies.yaml +++ b/config/dependencies.yaml @@ -129,6 +129,7 @@ react: axios: *axios highlight.js: "^11.11.0" html-react-parser: "^5.0.7" + luxon: "^3.5.0" nuqs: "^1.17.6" react-markdown: "^9.0.1" use-local-storage-state: "19.1.0" diff --git a/config/exceptions.yaml b/config/exceptions.yaml index ac3e782f0be..05e96332f4a 100644 --- a/config/exceptions.yaml +++ b/config/exceptions.yaml @@ -84,6 +84,7 @@ packageJson: "./components/Icon": "./components/Icon/index.mjs" "./components/Input": "./components/Input/index.mjs" "./components/Json": "./components/Json/index.mjs" + "./components/KeyVal": "./components/KeyVal/index.mjs" "./components/Layout": "./components/Layout/index.mjs" "./components/LineDrawing": "./components/LineDrawing/index.mjs" "./components/Link": "./components/Link/index.mjs" @@ -96,6 +97,8 @@ packageJson: "./components/SignIn": "./components/SignIn/index.mjs" "./components/Spinner": "./components/Spinner/index.mjs" "./components/Tab": "./components/Tab/index.mjs" + "./components/Table": "./components/Table/index.mjs" + "./components/Time": "./components/Time/index.mjs" "./components/Yaml": "./components/Yaml/index.mjs" "./components/Xray": "./components/Xray/index.mjs" # Context @@ -104,6 +107,7 @@ packageJson: # Hooks "./hooks/useAccount": "./hooks/useAccount/index.mjs" "./hooks/useBackend": "./hooks/useBackend/index.mjs" + "./hooks/useSelection": "./hooks/useSelection/index.mjs" # Lib "./lib/RestClient": "./lib/RestClient/index.mjs" "./lib/logoPath": "./components/Logo/path.mjs" diff --git a/package-lock.json b/package-lock.json index df15e01c596..e5b5ba477b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28341,6 +28341,10 @@ "resolved": "designs/hugo", "link": true }, + "node_modules/@freesewing/i18n": { + "resolved": "packages/i18n", + "link": true + }, "node_modules/@freesewing/jaeger": { "resolved": "designs/jaeger", "link": true @@ -47212,6 +47216,14 @@ "node": "14 || >=16.14" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -61552,6 +61564,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/i18n": { + "version": "3.3.0-rc.1", + "license": "MIT", + "devDependencies": {}, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "individual", + "url": "https://freesewing.org/patrons/join" + } + }, "packages/models": { "name": "@freesewing/models", "version": "3.3.0-rc.1", @@ -61925,6 +61949,7 @@ "axios": "1.7.2", "highlight.js": "^11.11.0", "html-react-parser": "^5.0.7", + "luxon": "^3.5.0", "nuqs": "^1.17.6", "react-markdown": "^9.0.1", "use-local-storage-state": "19.1.0", diff --git a/packages/react/components/Account/Pattern.mjs b/packages/react/components/Account/Pattern.mjs new file mode 100644 index 00000000000..f84c1d9ce04 --- /dev/null +++ b/packages/react/components/Account/Pattern.mjs @@ -0,0 +1,401 @@ +// Dependencies +import orderBy from 'lodash/orderBy.js' +import { + cloudflareImageUrl, + capitalize, + shortDate, + horFlexClasses, + newPatternUrl, +} from '@freesewing/utils' +import { urls, control as controlConfig } from '@freesewing/config' + +// Context +import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus' +import { ModalContext } from '@freesewing/react/context/Modal' + +// Hooks +import React, { useState, useEffect, useContext } from 'react' +import { useAccount } from '@freesewing/react/hooks/useAccount' +import { useBackend } from '@freesewing/react/hooks/useBackend' +import { useSelection } from '@freesewing/react/hooks/useSelection' + +// Components +import Markdown from 'react-markdown' +import { + StringInput, + MarkdownInput, + PassiveImageInput, + ListInput, +} from '@freesewing/react/components/Input' +import { Link as WebLink, AnchorLink } from '@freesewing/react/components/Link' +import { + BoolNoIcon, + BoolYesIcon, + CloneIcon, + EditIcon, + FreeSewingIcon, + OkIcon, + NoIcon, + PatternIcon, + ShowcaseIcon, + ResetIcon, + UploadIcon, +} from '@freesewing/react/components/Icon' +import { DisplayRow } from './shared.mjs' +import { TimeAgo } from '@freesewing/react/components/Time' +import { Popout } from '@freesewing/react/components/Popout' +import { ModalWrapper } from '@freesewing/react/components/Modal' +import { KeyVal } from '@freesewing/react/components/KeyVal' + +export const Pattern = ({ id, Link }) => { + if (!Link) Link = WebLink + // Hooks + const { account, control } = useAccount() + const { setLoadingStatus } = useContext(LoadingStatusContext) + const backend = useBackend() + + // 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, 'Loading pattern from backend']) + const [status, body] = await backend.getPattern(id) + if (status === 200) { + setPattern(body.pattern) + setName(body.pattern.name) + setImage(body.pattern.image) + setIsPublic(body.pattern.public ? true : false) + setNotes(body.pattern.notes) + setLoadingStatus([true, 'Loaded pattern', true, true]) + } else setLoadingStatus([true, 'An error occured. Please report this', true, false]) + } + if (id) getPattern() + }, [id]) + + const save = async () => { + setLoadingStatus([true, 'Gathering info']) + // 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, 'Saving pattern']) + const [status, body] = await backend.updatePattern(pattern.id, data) + if (status === 200 && body.result === 'success') { + setPattern(body.pattern) + setEdit(false) + setLoadingStatus([true, 'Nailed it', true, true]) + } else setLoadingStatus([true, 'An error occured. Please report this.', true, false]) + } + + const clone = async () => { + setLoadingStatus([true, 'Cloning pattern']) + // Compile data + const data = { ...pattern } + delete data.id + delete data.createdAt + delete data.data + delete data.userId + delete data.img + data.settings = JSON.parse(data.settings) + const [status, body] = await backend.createPattern(data) + if (status === 201 && body.result === 'created') { + setLoadingStatus([true, 'Loading newly created pattern', true, true]) + window.location = `/account/pattern/?id=${body.pattern.id}` + } else setLoadingStatus([true, 'We failed to create this pattern', true, false]) + } + + const togglePublic = async () => { + setLoadingStatus([true, 'Updating pattern']) + // Compile data + const data = { public: !pattern.public } + const [status, body] = await backend.updatePattern(pattern.id, data) + if (status === 200 && body.result === 'success') { + setPattern(body.pattern) + setLoadingStatus([true, 'Nailed it', true, true]) + } else setLoadingStatus([true, 'An error occured. Please report this.', true, false]) + } + if (!pattern) return null + + const header = ( + + ) + + if (!edit) + return ( +
+ {pattern.public ? ( + +
This is the private view of your pattern
+

+ Everyone can access the public view since this is a public pattern. +
+ But only you can access this private view. +

+

+ + + Public View + +

+
+ ) : null} + {header} + {control >= controlConfig.account.patterns.notes && ( + <> +

Notes

+ {pattern.notes} + + )} +
+ ) + + return ( +
+

Edit pattern {pattern.name}

+ + {/* Name is always shown */} + + val && val.length > 0} + /> + + {/* img: Control level determines whether or not to show this */} + + {account.control >= controlConfig.account.sets.img ? ( + val.length > 0} + /> + ) : null} + + {/* public: Control level determines whether or not to show this */} + + {account.control >= controlConfig.account.patterns.public ? ( + + Public Pattern + +
+ ), + desc: 'Public patterns can be shared with other FreeSewing users', + }, + { + val: false, + label: ( +
+ Private Pattern + +
+ ), + desc: 'Private patterns are yours and yours alone', + }, + ]} + current={isPublic} + /> + ) : null} + + {/* notes: Control level determines whether or not to show this */} + + {account.control >= controlConfig.account.patterns.notes ? ( + + ) : null} +
+ + +
+ + ) +} + +export const PatternCard = ({ + pattern, + href = false, + onClick = false, + useA = false, + size = 'md', + Link = false, +}) => { + if (!Link) Link = WebLink + 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 w-${s} aspect-square`, + 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}
+} + +const BadgeLink = ({ label, href }) => ( + + {label} + +) + +/** + * Helper component to show the pattern title, image, and various buttons + */ +const PatternHeader = ({ + pattern, + Link, + account, + setModal, + setEdit, + togglePublic, + save, + clone, +}) => ( + <> +

{pattern.name}

+
+ + } color="secondary" /> + } color="secondary" /> + +
+
+
+ +
+
+ {account.control > 3 && (pattern?.public || pattern.userId === account.id) ? ( +
+ + +
+ ) : ( + + )} + + {account.control > 3 ? ( + + ) : null} + {pattern.userId === account.id && ( + <> + + Update Pattern + + + + + )} +
+
+ +) diff --git a/packages/react/components/Account/Patterns.mjs b/packages/react/components/Account/Patterns.mjs new file mode 100644 index 00000000000..16dc2a463c8 --- /dev/null +++ b/packages/react/components/Account/Patterns.mjs @@ -0,0 +1,236 @@ +// Dependencies +import orderBy from 'lodash/orderBy.js' +import { capitalize, shortDate } from '@freesewing/utils' + +// Context +import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus' +//import { ModalContext } from '@freesewing/react/context/Modal' + +// Hooks +import React, { useState, useEffect, useContext } from 'react' +import { useAccount } from '@freesewing/react/hooks/useAccount' +import { useBackend } from '@freesewing/react/hooks/useBackend' +import { useSelection } from '@freesewing/react/hooks/useSelection' + +//import { +// measurements, +// isDegreeMeasurement, +// control as controlConfig, +// urls, +//} from '@freesewing/config' +//import { measurements as measurementTranslations } from '@freesewing/i18n' +//import { measurements as designMeasurements } from '@freesewing/collection' +//import { +// cloudflareImageUrl, +// capitalize, +// formatMm, +// horFlexClasses, +// linkClasses, +// notEmpty, +// roundDistance, +// shortDate, +// timeAgo, +//} from '@freesewing/utils' + +//// Hooks +//import React, { useState, useEffect, Fragment, useContext } from 'react' +//import { useAccount } from '@freesewing/react/hooks/useAccount' +//import { useBackend } from '@freesewing/react/hooks/useBackend' +// Components +import { TableWrapper } from '@freesewing/react/components/Table' +import { PatternCard } from '@freesewing/react/components/Account' +import { Link as WebLink } from '@freesewing/react/components/Link' +import { + BoolNoIcon, + BoolYesIcon, + // CloneIcon, + // CuratedMeasurementsSetIcon, + // EditIcon, + // ShowcaseIcon, + // NewMeasurementsSetIcon, + // NoIcon, + // OkIcon, + PlusIcon, + RightIcon, + // ResetIcon, + TrashIcon, + // UploadIcon, + // // WarningIcon, + // // BoolYesIcon, + // // BoolNoIcon, +} from '@freesewing/react/components/Icon' +//import { BookmarkButton, MsetCard } from '@freesewing/react/components/Account' +//import { +// DesignInput, +// MarkdownInput, +// ListInput, +// MeasieInput, +// PassiveImageInput, +// StringInput, +// ToggleInput, +//} from '@freesewing/react/components/Input' +//import { DisplayRow } from './shared.mjs' +//import Markdown from 'react-markdown' +//import { ModalWrapper } from '@freesewing/react/components/Modal' +//import { Json } from '@freesewing/react/components/Json' +//import { Yaml } from '@freesewing/react/components/Yaml' +//import { Popout } from '@freesewing/react/components/Popout' + +const t = (input) => { + console.log('t called', input) + return input +} + +/* + * Component for the account/patterns page + * + * @params {object} props - All React props + * @params {function} Link - A framework specific Link component for client-side routing + */ +export const Patterns = ({ Link = false }) => { + if (!Link) Link = WebLink + + // State + const [patterns, setPatterns] = useState([]) + const [refresh, setRefresh] = useState(0) + const [order, setOrder] = useState('id') + const [desc, setDesc] = useState(false) + + // Hooks + const backend = useBackend() + const { setLoadingStatus, LoadingProgress } = useContext(LoadingStatusContext) + const { count, selection, setSelection, toggle, toggleAll } = useSelection(patterns) + + // Effects + useEffect(() => { + const getPatterns = async () => { + setLoadingStatus([true, 'Loading patterns from backend']) + const [status, body] = await backend.getPatterns() + console.log({ status, body }) + if (status === 200) { + setPatterns(body.patterns) + setLoadingStatus([true, 'Patterns loaded', true, true]) + } else setLoadingStatus([false, 'Failed to load patterns from backend', true, true]) + } + getPatterns() + }, [refresh]) + + // Helper to delete one or more patterns + const removeSelectedPatterns = async () => { + let i = 0 + for (const pattern in selection) { + i++ + await backend.removePattern(pattern) + setLoadingStatus([ + true, + , + ]) + } + setSelection({}) + setRefresh(refresh + 1) + setLoadingStatus([true, 'Nailed it', true, true]) + } + + const fields = { + id: '#', + img: 'Image', + name: 'Name', + design: 'Design', + createdAt: 'Date', + public: 'Public', + } + + return ( + <> +
+ + + + Create a new pattern + +
+ + + + + + {Object.keys(fields).map((field) => ( + + ))} + + + + {orderBy(patterns, order, desc ? 'desc' : 'asc').map((pattern, i) => ( + + + + + + + + + + ))} + +
+ + + +
+ toggle(pattern.id)} + /> + {pattern.id} + + + + {pattern.name} + + + + {capitalize(pattern.design)} + + {shortDate(pattern.createdAt)} + {pattern.public ? : } +
+
+ + ) +} diff --git a/packages/react/components/Account/Sets.mjs b/packages/react/components/Account/Sets.mjs index 37450c53148..667d0a72c70 100644 --- a/packages/react/components/Account/Sets.mjs +++ b/packages/react/components/Account/Sets.mjs @@ -3,71 +3,13 @@ import { measurements } from '@freesewing/config' import { cloudflareImageUrl, capitalize } from '@freesewing/utils' // Context import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus' -//import { ModalContext } from '@freesewing/react/context/Modal' // Hooks import React, { useState, useEffect, Fragment, useContext } from 'react' import { useAccount } from '@freesewing/react/hooks/useAccount' import { useBackend } from '@freesewing/react/hooks/useBackend' // Components import { Link as WebLink } from '@freesewing/react/components/Link' -import { - NoIcon, - OkIcon, - PlusIcon, - TrashIcon, - UploadIcon, - // EditIcon, - // ResetIcon, - // WarningIcon, - // CameraIcon, - // CsetIcon, - // BoolYesIcon, - // BoolNoIcon, - // CloneIcon, -} from '@freesewing/react/components/Icon' - -//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 { 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' +import { NoIcon, OkIcon, PlusIcon, TrashIcon, UploadIcon } from '@freesewing/react/components/Icon' /* * The component for the an account/sets page @@ -323,963 +265,3 @@ export const MsetCard = ({ // Returns a div return
{inner}
} - -/* -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 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} - - - ) -} - -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/index.mjs b/packages/react/components/Account/index.mjs index 83df04c04ff..b9c44aafa38 100644 --- a/packages/react/components/Account/index.mjs +++ b/packages/react/components/Account/index.mjs @@ -4,5 +4,18 @@ import { Bookmarks, BookmarkButton } from './Bookmarks.mjs' import { Links } from './Links.mjs' import { Set, NewSet } from './Set.mjs' import { Sets, MsetCard } from './Sets.mjs' +import { Patterns } from './Patterns.mjs' +import { Pattern, PatternCard } from './Pattern.mjs' -export { Bookmarks, BookmarkButton, Links, Set, NewSet, Sets, MsetCard } +export { + Bookmarks, + BookmarkButton, + Links, + Set, + NewSet, + Sets, + MsetCard, + Patterns, + Pattern, + PatternCard, +} diff --git a/packages/react/components/KeyVal/index.mjs b/packages/react/components/KeyVal/index.mjs new file mode 100644 index 00000000000..b0bbe602da2 --- /dev/null +++ b/packages/react/components/KeyVal/index.mjs @@ -0,0 +1,38 @@ +import React, { useState, useContext } from 'react' +import { CopyToClipboard as Copy } from 'react-copy-to-clipboard' +import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus' + +export const KeyVal = ({ k, val, color = 'primary', small = false }) => { + const [copied, setCopied] = useState(false) + const { setLoadingStatus } = useContext(LoadingStatusContext) + + return ( + handleCopied(setCopied, setLoadingStatus, k)}> + + + ) +} + +const sharedClasses = `px-1 text-sm font-medium whitespace-nowrap border-2` + +const handleCopied = (setCopied, setLoadingStatus, label) => { + setCopied(true) + setLoadingStatus([ + true, + label ? `${label} copied to clipboard` : 'Copied to clipboard', + true, + true, + ]) + setTimeout(() => setCopied(false), 1000) +} diff --git a/packages/react/components/Pattern/index.mjs b/packages/react/components/Pattern/index.mjs index e445cf58e62..caec4f1eed9 100644 --- a/packages/react/components/Pattern/index.mjs +++ b/packages/react/components/Pattern/index.mjs @@ -1,3 +1,6 @@ +// Dependencies +import { cloudflareImageUrl } from '@freesewing/utils' +// Components import React, { forwardRef } from 'react' import { Svg as DefaultSvg } from './svg.mjs' import { Defs as DefaultDefs } from './defs.mjs' @@ -11,6 +14,7 @@ import { Grid as DefaultGrid } from './grid.mjs' import { Text as DefaultText, TextOnPath as DefaultTextOnPath } from './text.mjs' import { Circle as DefaultCircle } from './circle.mjs' import { getId, getProps, withinPartBounds, translateStrings } from './utils.mjs' +import { Link as WebLink } from '@freesewing/react/components/Link' /* * Allow people to override these components diff --git a/packages/react/components/Table/index.mjs b/packages/react/components/Table/index.mjs new file mode 100644 index 00000000000..71ad2e81371 --- /dev/null +++ b/packages/react/components/Table/index.mjs @@ -0,0 +1,9 @@ +import React from 'react' + +/* + * Tables on mobile will almost always break the layout + * unless we set the overflow behaviour explicitly + */ +export const TableWrapper = ({ children }) => ( +
{children}
+) diff --git a/packages/react/components/Time/index.mjs b/packages/react/components/Time/index.mjs new file mode 100644 index 00000000000..51d23f8c5b9 --- /dev/null +++ b/packages/react/components/Time/index.mjs @@ -0,0 +1,58 @@ +import React from 'react' +import { DateTime, Interval } from 'luxon' + +const day = 86400000 +const hour = 3600000 +const minute = 60000 +const second = 1000 + +export const DateAndTime = ({ iso }) => { + const dt = DateTime.fromISO(iso) + return dt.toLocaleString(DateTime.DATETIME_MED) +} + +export const TimeForHumans = ({ iso, future = false }) => { + const suffix = future ? 'from now' : 'ago' + const dates = [DateTime.fromISO(iso), DateTime.now()] + if (future) dates.reverse() + const i = Interval.fromDateTimes(...dates) + .toDuration(['minutes', 'hours', 'days', 'months', 'years']) + .toObject() + const years = Math.floor(i.years) + const months = Math.floor(i.months) + const days = Math.floor(i.days) + const hours = Math.floor(i.hours) + const minutes = Math.floor(i.minutes) + if (years < 1 && months < 1 && days < 1 && hours < 1 && minutes < 1) return `seconds ${suffix}` + else if (years < 1 && months < 1 && days < 1 && hours < 1) + return minutes < 2 ? `one minute ${suffix}` : `${minutes} minutes ${suffix}` + else if (i.years < 1 && i.months < 1 && i.days < 1) + return hours < 2 ? `${hours * 60 + minutes} minutes ${suffix}` : `${hours} hours ${suffix}` + else if (years < 1 && months < 1) + return days < 2 ? `${days * 24 + hours} hours ${suffix}` : `${days} days ${suffix}` + else if (years < 1) + return months < 4 ? `${months} months and ${days} days ${suffix}` : `${months} months ${suffix}` + if (years < 3) return `${years * 12 + i.months} months ${suffix}` + return `${years} years ${suffix}` +} + +export const TimeAgo = (props) => +export const TimeToGo = (props) => + +export const TimeAgoBrief = ({ time }) => { + const d = Math.floor(Date.now() - time) + if (d > day) return `${Math.floor(d / day)}d ago` + if (d > hour) return `${Math.floor(d / hour)}h ago` + if (d > minute * 2) return `${Math.floor(d / minute)}m ago` + if (d > second) return `${Math.floor(d / second)}s ago` + return `${d}ms ago` +} + +export const TimeToGoBrief = ({ time }) => { + const d = Math.floor(time * 1000 - Date.now()) + if (d > day) return `${Math.floor(d / day)}d` + if (d > hour) return `${Math.floor(d / hour)}h` + if (d > minute * 2) return `${Math.floor(d / minute)}m` + if (d > second) return `${Math.floor(d / second)}s` + return `${d}s` +} diff --git a/packages/react/hooks/useSelection/index.mjs b/packages/react/hooks/useSelection/index.mjs new file mode 100644 index 00000000000..ac3825ef3c0 --- /dev/null +++ b/packages/react/hooks/useSelection/index.mjs @@ -0,0 +1,40 @@ +import React, { useState } from 'react' + +export const useSelection = (items) => { + const [selection, setSelection] = useState({}) + + /* + * This variable keeps track of how many are selected + */ + const count = Object.keys(selection).length + + /* + * This method toggles a single item in the selection + */ + const toggle = (id) => { + const newSelection = { ...selection } + if (newSelection[id]) delete newSelection[id] + else newSelection[id] = 1 + setSelection(newSelection) + } + + /* + * This method toggles all on or off + */ + const toggleAll = () => { + if (count === items.length) setSelection({}) + else { + const newSelection = {} + for (const item of items) newSelection[item.id] = 1 + setSelection(newSelection) + } + } + + return { + count, + selection, + setSelection, + toggle, + toggleAll, + } +} diff --git a/packages/react/package.json b/packages/react/package.json index 8681a788e92..f0d10e03798 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -37,6 +37,7 @@ "./components/Icon": "./components/Icon/index.mjs", "./components/Input": "./components/Input/index.mjs", "./components/Json": "./components/Json/index.mjs", + "./components/KeyVal": "./components/KeyVal/index.mjs", "./components/Layout": "./components/Layout/index.mjs", "./components/LineDrawing": "./components/LineDrawing/index.mjs", "./components/Link": "./components/Link/index.mjs", @@ -49,12 +50,15 @@ "./components/SignIn": "./components/SignIn/index.mjs", "./components/Spinner": "./components/Spinner/index.mjs", "./components/Tab": "./components/Tab/index.mjs", + "./components/Table": "./components/Table/index.mjs", + "./components/Time": "./components/Time/index.mjs", "./components/Yaml": "./components/Yaml/index.mjs", "./components/Xray": "./components/Xray/index.mjs", "./context/LoadingStatus": "./context/LoadingStatus/index.mjs", "./context/Modal": "./context/Modal/index.mjs", "./hooks/useAccount": "./hooks/useAccount/index.mjs", "./hooks/useBackend": "./hooks/useBackend/index.mjs", + "./hooks/useSelection": "./hooks/useSelection/index.mjs", "./lib/RestClient": "./lib/RestClient/index.mjs", "./lib/logoPath": "./components/Logo/path.mjs" }, @@ -69,6 +73,7 @@ "axios": "1.7.2", "highlight.js": "^11.11.0", "html-react-parser": "^5.0.7", + "luxon": "^3.5.0", "nuqs": "^1.17.6", "react-markdown": "^9.0.1", "use-local-storage-state": "19.1.0", diff --git a/packages/utils/src/index.mjs b/packages/utils/src/index.mjs index 98d7dd80b05..bff94a1676b 100644 --- a/packages/utils/src/index.mjs +++ b/packages/utils/src/index.mjs @@ -244,6 +244,12 @@ export function measurementAsUnits(mmValue, units = 'metric') { return round(mmValue / (units === 'imperial' ? 25.4 : 10), 3) } +/** Generate a URL to create a new pattern with a given design, settings, and view */ +export const newPatternUrl = ({ design, settings = {}, view = 'draft' }) => + `/-/#settings=${encodeURIComponent( + JSON.stringify(settings) + )}&view=${encodeURIComponent('"' + view + '"')}` + /* * A method to ensure input is not empty * diff --git a/sites/org/docs/account/pattern/index.mdx b/sites/org/docs/account/pattern/index.mdx new file mode 100644 index 00000000000..a697004b15a --- /dev/null +++ b/sites/org/docs/account/pattern/index.mdx @@ -0,0 +1,17 @@ +--- +title: Pattern +sidebar_label: ' ' +sidebar_position: 99 +--- + +import { getSearchParam } from '@freesewing/utils' +import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus' +import { RoleBlock } from '@freesewing/react/components/Role' +import { Pattern } from '@freesewing/react/components/Account' +import Link from '@docusaurus/Link' + + + + + + diff --git a/sites/org/docs/account/patterns/index.mdx b/sites/org/docs/account/patterns/index.mdx new file mode 100644 index 00000000000..00d5a51ea7b --- /dev/null +++ b/sites/org/docs/account/patterns/index.mdx @@ -0,0 +1,15 @@ +--- +title: Your Patterns +sidebar_position: 3 +--- + +import { DocusaurusDoc } from '@freesewing/react/components/Docusaurus' +import { RoleBlock } from '@freesewing/react/components/Role' +import { Patterns as AccountPatterns } from '@freesewing/react/components/Account' +import Link from '@docusaurus/Link' + + + + + +