1
0
Fork 0
freesewing/packages/react/components/Collection/index.mjs

575 lines
20 KiB
JavaScript
Raw Normal View History

// Dependencies
import { atomWithHash } from 'jotai-location'
import {
about,
collection,
tags,
techniques,
designers,
developers,
examples,
measurements,
requiredMeasurements,
optionalMeasurements,
} from '@freesewing/collection'
import { capitalize, linkClasses, mutateObject } from '@freesewing/utils'
import { measurements as measurementsTranslations } from '@freesewing/i18n'
// Context
import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus'
import { ModalContext } from '@freesewing/react/context/Modal'
// Hooks
import React, { useState, useContext, Fragment } from 'react'
import { useAtom } from 'jotai'
// Components
import { Link as WebLink, AnchorLink } from '@freesewing/react/components/Link'
import {
CircleIcon,
CisFemaleIcon,
DocsIcon,
FilterIcon,
HeartIcon,
NewPatternIcon,
ResetIcon,
ShowcaseIcon,
} from '@freesewing/react/components/Icon'
import {
lineDrawingsFront as lineDrawings,
lineDrawingsBack,
} from '@freesewing/react/components/LineDrawing'
import { IconButton } from '@freesewing/react/components/Button'
import { ModalWrapper } from '@freesewing/react/components/Modal'
import { KeyVal } from '@freesewing/react/components/KeyVal'
import { MissingLinedrawing } from '../LineDrawing/missing.mjs'
const filterAtom = atomWithHash('filter', { example: true })
export const useFilter = () => {
return useAtom(filterAtom)
}
/**
* React component to show the FreeSewing collection and pick a design
*
* @param {object} props - All React props
* @param {function} Link - An optional framework specific Link component for client-side routing
* @param {bool} editor - Set this to when loaded in the editor (this will make the display more dense)
* @param {bool} onClick - Set this to trigger an onClick event, rather than using links
*/
export const Collection = ({ Link = false, linkTo = 'about', editor = false, onClick = false }) => {
if (!Link) Link = WebLink
// State
const [filter, setFilter] = useFilter()
const [showFilters, setShowFilters] = useState(false)
/*
* Apply filter
*/
const filtered = {}
for (const d of collection) {
let skip = false
if (
filter.tag &&
filter.tag.filter((tag) => about[d].tags.includes(tag)).length < filter.tag.length
)
skip = true
if (
filter.tech &&
filter.tech.filter((tech) => about[d].techniques.includes(tech)).length < filter.tech.length
)
skip = true
if (filter.difficulty && filter.difficulty !== about[d].difficulty) skip = true
if (!skip) filtered[d] = d
}
const updateFilter = (path, val) => {
// Allow clicking the same difficulty to remove it as a filter
if (path === 'difficulty' && val === filter.difficulty) val = 'unset'
const newFilter = mutateObject({ ...filter }, path, val)
setFilter(newFilter)
}
const toggle = (type, val) => {
const current = filter[type] || []
const newSet = new Set(current)
if (newSet.has(val)) newSet.delete(val)
else newSet.add(val)
updateFilter(type, [...newSet])
}
return (
<>
<div className="tw:max-w-7xl tw:m-auto">
<div className="tw:flex tw:flex-row tw:flex-wrap tw:gap-1 tw:justify-center tw:font-medium tw:mb-2">
{Object.keys(filtered)
.sort()
.map((d) =>
onClick ? (
<button
key={d}
onClick={() => onClick(d)}
className="tw:text-secondary tw:decoration-2 tw:underline tw:capitalize tw:hover:decoration-4 tw:hover:text-secondary tw:bg-transparent tw:border-0 tw:font-medium tw:p-0 tw:text-base tw:hover:cursor-pointer"
>
{d}
</button>
) : (
<Link
key={d}
href={linkBuilders[linkTo](d)}
className="tw:text-secondary tw:decoration-2 tw:underline tw:capitalize tw:hover:decoration-4 tw:hover:text-secondary"
>
{d}
</Link>
)
)}
</div>
{showFilters ? (
<>
<h6 className="tw:text-center tw:mb-0 tw:mt-4">
Filters ({Object.keys(filtered).length}/{collection.length})
</h6>
<div className="tw:flex tw:flex-row tw:gap-1 tw:items-center tw:justify-center tw:flex-wrap tw:my-2">
<b>Tags:</b>
{tags.map((tag) => (
<button
key={tag}
className={`tw:daisy-badge tw:font-medium tw:hover:shadow tw:hover:cursor-pointer
${
filter?.tag && Array.isArray(filter.tag) && filter.tag.includes(tag)
? 'tw:daisy-badge-success hover:tw:daisy-badge-error'
: 'tw:daisy-badge-primary hover:tw:daisy-badge-success'
}`}
onClick={() => toggle('tag', tag)}
>
{tag}
</button>
))}
</div>
<div className="tw:flex tw:flex-row tw:gap-1 tw:items-center tw:justify-center tw:flex-wrap tw:my-4">
<b>Techniques</b>
{techniques.sort().map((tech) => (
<button
key={tech}
className={`tw:daisy-badge tw:font-medium tw:hover:shadow
${
filter?.tech && Array.isArray(filter.tech) && filter.tech.includes(tech)
? 'tw:daisy-badge tw:daisy-badge-success hover:tw:daisy-badge-error'
: 'tw:daisy-badge tw:daisy-badge-accent hover:tw:daisy-badge-success'
}`}
onClick={() => toggle('tech', tech)}
>
{tech}
</button>
))}
</div>
<div className="tw:flex tw:flex-row tw:gap-2 tw:items-center tw:justify-center tw:flex-wrap tw:my-4">
<b>Difficulty:</b>
{[1, 2, 3, 4, 5].map((score) => (
<button
onClick={() => updateFilter('difficulty', score)}
key={score}
className={`tw:daisy-btn tw:daisy-btn-sm ${
filter.difficulty === score
? 'tw:daisy-btn-secondary tw:daisy-btn-outline'
: 'tw:daisy-btn-ghost'
}`}
>
<Difficulty score={score} />
</button>
))}
</div>
<div className="tw:flex tw:flex-row tw:gap-4 tw:items-center tw:justify-center tw:flex-wrap tw:my-2">
<button
className="tw:daisy-btn tw:daisy-btn-secondary tw:daisy-btn-outline"
onClick={() => updateFilter('example', !filter.example)}
>
{filter.example ? <CisFemaleIcon /> : <ShowcaseIcon />}
{filter.example ? 'Show Line Drawings' : 'Show Examples'}
</button>
<button
className="tw:daisy-btn tw:daisy-btn-secondary tw:daisy-btn-outline"
onClick={() => setFilter({ example: 1 })}
>
<ResetIcon />
Clear Filter
</button>
<button
className="tw:daisy-btn tw:daisy-btn-secondary tw:daisy-btn-outline"
onClick={() => setShowFilters(false)}
>
<FilterIcon />
Hide Filters
</button>
</div>
</>
) : (
<div className="tw:flex tw:flex-row tw:gap-4 tw:items-center tw:justify-center tw:flex-wrap tw:my-2">
<button
className="tw:daisy-btn tw:daisy-btn-secondary tw:daisy-btn-outline"
onClick={() => updateFilter('example', !filter.example)}
>
{filter.example ? <CisFemaleIcon /> : <ShowcaseIcon />}
{filter.example ? 'Show Line Drawings' : 'Show Examples'}
</button>
<button
className="tw:daisy-btn tw:daisy-btn-secondary tw:daisy-btn-outline"
onClick={() => setShowFilters(true)}
>
<FilterIcon />
Show Filters
</button>
</div>
)}
</div>
<div
className={`tw:grid tw:grid-cols-2 tw:gap-2 tw:mt-4 tw:justify-center tw:sm:grid-cols-3 tw:md:grid-cols-4 ${editor ? 'tw:lg:grid-cols-6 tw:2xl:grid-cols-12' : ''} tw:mb-8`}
>
{Object.keys(filtered)
.sort()
.map((d) => (
<DesignCard
name={d}
key={d}
linkTo={linkTo}
onClick={onClick}
lineDrawing={filter.example ? false : true}
/>
))}
</div>
</>
)
}
/*
* A helper component to show a design technique
*
* @param {object} props - All React props
* @param {function} props.Link - A Link component, typically specific to the framework for client-side routing
* @param {string} props.technique - The technique name/id
*/
const Technique = ({ Link = WebLink, technique }) => (
<Link
href={`/designs/techniques/${technique}`}
className="tw:daisy-badge tw:daisy-badge-accent hover:tw:daisy-badge-secondary tw:hover:shadow tw:font-medium"
>
{technique}
</Link>
)
/*
* A helper component to show a design tag
*
* @param {object} props - All React props
* @param {function} props.Link - A Link component, typically specific to the framework for client-side routing
* @param {string} props.tag - The tag name/id
*/
const Tag = ({ Link = WebLink, technique }) => (
<Link
href={`/designs/tags/${tag}`}
className="tw:daisy-badge tw:daisy-badge-primary hover:tw:daisy-badge-secondary tw:hover:shadow tw:font-medium"
>
{tag}
</Link>
)
const DesignCard = ({ name, lineDrawing = false, linkTo, Link, onClick }) => {
if (!Link) Link = WebLink
const LineDrawing =
lineDrawing && lineDrawings[name]
? lineDrawings[name]
: ({ className }) => <div className={className}></div>
const exampleImageUrl = examples.href[name] ? examples.href[name] : noExample
const bg = { aspectRatio: '1/1.4' }
if (!lineDrawing) {
bg.backgroundImage = `url(${exampleImageUrl}`
bg.backgroundSize = 'cover'
bg.backgroundPosition = 'center center'
}
const inner = (
<div
className={`tw:flex tw:flex-col tw:flex-nowrap tw:items-start tw:justify-between tw:gap-2 tw:border-neutral-500 tw:group-hover:border-secondary
tw:w-full tw:h-full tw:border tw:border-2 tw:border-solid tw:p-0 tw:relative tw:rounded-lg tw:rounded-lg`}
style={bg}
>
<h5
className={`tw:text-center tw:py-2 tw:px-4 tw:rounded-t tw:m-0 tw:w-full tw:group-hover:no-underline tw:group-hover:bg-secondary/70
${lineDrawing ? '' : 'tw:bg-neutral/80 tw:text-neutral-content'}`}
>
{about[name].name}
</h5>
{lineDrawing ? (
<div className="tw:p-1 tw:grow tw:w-full tw:h-auto tw:square tw:text-center">
<LineDrawing className="tw:max-w-full tw:m-auto tw:my-4 tw:text-base-content" />
</div>
) : (
<span />
)}
<div
className={`tw:flex tw:flex-row tw:items-center tw:justify-center tw:py-1 tw:px-2 tw:rounded-b tw:m-0 tw:w-full
${lineDrawing ? '' : `tw:text-neutral-content`}`}
>
<Difficulty score={about[name].difficulty} className="tw:group-hover:text-secondary" />
</div>
</div>
)
return onClick ? (
<button
onClick={() => onClick(name)}
className="tw:hover:bg-secondary/10 tw:rounded-lg tw:group tw:hover:no-underline tw:bg-transparent tw:border-0 tw:hover:cursor-pointer tw:p-0"
title={about[name].description}
>
{inner}
</button>
) : (
<Link
href={linkBuilders[linkTo](name)}
className="tw:hover:bg-secondary/10 tw:rounded-lg tw:group tw:hover:no-underline"
title={about[name].description}
>
{inner}
</Link>
)
}
/*
* A helper component to show difficulety of a design
*
* @param {object} props - All React props
* @param {number} props.score - The difficulty score of the design (1-5)
*/
const Difficulty = ({ score = 0, className = '' }) => (
<div className={`tw:flex tw:flex-row tw:items-center ${className}`}>
{[0, 1, 2, 3, 4].map((i) => (
<CircleIcon key={i} fill={i < score ? true : false} className={`tw:w-4 tw:h-4`} />
))}
</div>
)
const linkBuilders = {
new: (design) =>
`/editor/#s={%22design%22%3A%22${design.toLowerCase()}%22%2C%22view%22%3A%22draft%22}`,
docs: (design) => `/docs/designs/${design.toLowerCase()}/`,
about: (design) => `/designs/${design.toLowerCase()}/`,
}
const noExample =
'https://images.pexels.com/photos/5626595/pexels-photo-5626595.jpeg?cs=srgb&fm=jpg&w=640&h=427'
/**
* React component to show info about a FreeSewing design
*
* @param {object} props - All React props
* @param {string} design - The name/id of the design
* @param {function} Link - An optional framework specific Link component for client-side routing
*/
export const DesignInfo = ({ Link = false, design = false, noDocsLink = false }) => {
if (!Link) Link = WebLink
// State
const [back, setBack] = useState(false)
// Context
const { setModal, clearModal } = useContext(ModalContext)
const { setLoadingStatus } = useContext(LoadingStatusContext)
if (!design) return null
// Line drawings
const LineDrawing = lineDrawings[design] || MissingLinedrawing
const LineDrawingBack = lineDrawingsBack[design] || null
// Make sure these always hold arrays, that way we can just map() over them in the JSX output
const codeBy = Array.isArray(about[design].code) ? about[design].code : [about[design].code]
const designBy = Array.isArray(about[design].design)
? about[design].design
: [about[design].design]
const tags = about[design].tags || []
const techniques = about[design].techniques || []
const colors = {
1: 'success',
2: 'success',
3: 'warning',
4: 'warning',
5: 'error',
}
const makeButton = (
<div className={`tw:grid tw:grid-cols-1 tw:gap-2 tw:mb-4`}>
<IconButton href={`/editor/#s={"design"%3A"${design}"%2C"view"%3A"draft"}`} color="primary">
<NewPatternIcon className="tw:w-8 tw:h-8" />
New {capitalize(design)} pattern
</IconButton>
</div>
)
const buttons = noDocsLink ? (
makeButton
) : (
<div className={`tw:grid tw:grid-cols-1 tw:lg:grid-cols-2 tw:gap-2 tw:mb-4`}>
<IconButton href={`/docs/designs/${design}`} color="secondary">
<DocsIcon className="tw:w-8 tw:h-8" />
Documentation
</IconButton>
{makeButton}
</div>
)
return (
<>
<div className="tw:lg:hidden">{buttons}</div>
<div className={`tw:grid tw:grid-cols-1 tw:lg:grid-cols-2 tw:gap-2`}>
<div className="tw:relative">
<div className="tw tw:top-0 tw:left-0">
{back ? <LineDrawingBack /> : <LineDrawing />}
</div>
{LineDrawingBack ? (
<button
className="tw:absolute tw:top-2 tw:right-4 tw:start-auto tw:daisy-btn tw:daisy-btn-neutral tw:daisy-btn-outline tw:daisy-btn-xs"
onClick={() => setBack(!back)}
>
{back ? 'Front' : 'Back'} view
</button>
) : null}
</div>
<div className="">
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">Description</div>
<span className="tw:text-xl">{about[design].description}</span>
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">By</div>
<div className="tw:flex tw:flex-row tw:flex-wrap tw:gap-1 items-center">
{codeBy.map((code) => (
<KeyVal key={code} k="code" val={code} color="secondary" />
))}
{designBy.map((code) => (
<KeyVal key={code} k="design" val={code} color="secondary" />
))}
</div>
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">Difficulty</div>
<Difficulty score={about[design].difficulty} />
{optionalMeasurements[design].length > 0 ? (
<>
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">
Optional Measurements
</div>
<div className="">
{optionalMeasurements[design].map((m, i) => (
<Fragment key={m}>
<Link
href={`/docs/measurements/${m.toLowerCase()}`}
key={m}
className={linkClasses}
>
{measurementsTranslations[m]}
</Link>
{i < optionalMeasurements[design].length - 1 ? <span>, </span> : null}
</Fragment>
))}
</div>
</>
) : null}
{requiredMeasurements[design].length > 0 ? (
<>
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">
Required Measurements
</div>
<div className="">
{requiredMeasurements[design].map((m, i) => (
<Fragment key={m}>
<Link
href={`/docs/measurements/${m.toLowerCase()}`}
key={m}
className={linkClasses}
>
{measurementsTranslations[m]}
</Link>
{i < requiredMeasurements[design].length - 1 ? <span>, </span> : null}
</Fragment>
))}
</div>
</>
) : null}
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">Tags</div>
<div className="tw:flex tw:flex-row tw:flex-wrap tw:gap-1 items-center">
{tags.map((tag) => (
<Link
key={tag}
className="tw:daisy-badge tw:daisy-badge-primary tw:font-medium tw:hover:shadow tw:hover:cursor-pointer"
href={`/designs/#filter={"example"%3Atrue%2C"tag"%3A["${tag}"]}`}
>
{tag}
</Link>
))}
</div>
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">Techniques</div>
<div className="tw:flex tw:flex-row tw:flex-wrap tw:gap-1 items-center">
{techniques.map((tech) => (
<Link
key={tech}
className="tw:daisy-badge tw:daisy-badge-accent tw:font-medium tw:hover:shadow tw:hover:cursor-pointer"
href={`/designs/#filter={"example"%3Atrue%2C"tag"%3A["${tech}"]}`}
>
{tech}
</Link>
))}
</div>
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">Examples</div>
<div className="tw:flex tw:flex-row tw:flex-wrap tw:gap-1 items-center">
<KeyVal
k="FreeSewing"
val="showcase"
color="secondary"
2025-04-02 19:02:57 +02:00
href={`/showcase/tags/${design}`}
Link={Link}
/>
<KeyVal
k="Instagram"
val={`#FreeSewing${capitalize(design)}`}
color="secondary"
href={`https://www.instagram.com/explore/search/keyword/?q=%23FreeSewing${capitalize(design)}`}
/>
</div>
<div className="tw:mt-2 tw:text-sm tw:opacity-70 tw:font-medium">Documentation</div>
<div className="tw:flex tw:flex-row tw:flex-wrap tw:gap-1 items-center">
<Link href={`/docs/designs/${design}/#notes`}>Designer Notes</Link>,
<Link href={`/docs/designs/${design}/#needs`}>What You Need</Link>,
<Link href={`/docs/designs/${design}/#fabric`}>Fabric Options</Link>,
<Link href={`/docs/designs/${design}/#cutting`}>Cutting Instructions</Link>,
<Link href={`/docs/designs/${design}/options/`}>Design Options</Link>,
<Link href={`/docs/designs/${design}/instructions/`}>Sewing Instructions</Link>
</div>
<div className="tw:my-4">{buttons}</div>
</div>
</div>
</>
)
}
const SharingIsCaring = ({ design }) => (
<>
<h2>
Use <b>#FreeSewing{capitalize(design)}</b> to facilitate discovery
</h2>
<p>
Please use the{' '}
<b>
<code>#FreeSewing{capitalize(design)}</code>
</b>{' '}
hashtag when discussing FreeSewing&apos;s <b>{capitalize(design)}</b> pattern online.
<br />
Doing so can help others discover your post, which really is a win-win.
</p>
<p>If you like, you can copy the hashtag below:</p>
</>
)