diff --git a/sites/backend/src/controllers/users.mjs b/sites/backend/src/controllers/users.mjs index 133230cf663..1b006f17576 100644 --- a/sites/backend/src/controllers/users.mjs +++ b/sites/backend/src/controllers/users.mjs @@ -105,6 +105,18 @@ UsersController.prototype.updateMfa = async (req, res, tools) => { return User.sendResponse(res) } +/* + * Returns a user profile + * + * See: https://freesewing.dev/reference/backend/api + */ +UsersController.prototype.profile = async (req, res, tools) => { + const User = new UserModel(tools) + await User.profile(req) + + return User.sendResponse(res) +} + /* * Checks whether a submitted username is available * diff --git a/sites/backend/src/models/flow.mjs b/sites/backend/src/models/flow.mjs index d11e1d0576d..c225caf47cd 100644 --- a/sites/backend/src/models/flow.mjs +++ b/sites/backend/src/models/flow.mjs @@ -133,9 +133,9 @@ FlowModel.prototype.uploadImage = async function ({ body, user }) { if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') /* - * Is img set? + * Is img or url set? */ - if (!body.img) return this.setResponse(400, 'imgMissing') + if (!body.img && !body.url) return this.setResponse(400, 'imgOrUrlMissing') /* * Is type set and valid? @@ -156,21 +156,21 @@ FlowModel.prototype.uploadImage = async function ({ body, user }) { if (!body.slug) return this.setResponse(400, 'slugMissing') /* - * Upload the image + * Prepare data for uploading the image */ - const id = `${body.type}-${body.slug}${ - body.subId && body.subId !== 'main' ? '-' + body.subId : '' - }` - const b64 = body.img - const metadata = { uploadedBy: user.uid } + const data = { + id: `${body.type}-${body.slug}${body.subId && body.subId !== 'main' ? '-' + body.subId : ''}`, + metadata: { uploadedBy: user.uid }, + } + if (body.img) data.b64 = body.img + else if (body.url) data.url = body.url + /* * You need to be a curator to overwrite (replace) an image. * Regular users can only update new images, not overwrite images. * If not, any user could overwrite any showcase image. */ - const imgId = this.rbac.curator(user) - ? await replaceImage({ b64, id, metadata }) - : await ensureImage({ b64, id, metadata }) + const imgId = this.rbac.curator(user) ? await replaceImage(data) : await ensureImage(data) /* * Return 200 and the image ID diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 6f91bde2cff..753c680cc32 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -16,6 +16,40 @@ export function UserModel(tools) { }) } +/* + * Loads a user from the database based on the where clause you pass it + * In addition prepares it for returning the account data + * This is guarded so it enforces access control and validates input + * This is an anonymous route returning limited info (profile data) + * + * @param {params} object - The request (URL) parameters + * @returns {UserModel} object - The UserModel + */ +UserModel.prototype.profile = async function ({ params }) { + /* + * Is id set? + */ + if (typeof params.id === 'undefined') return this.setResponse(403, 'idMissing') + + /* + * Try to find the record in the database + * Note that find checks lusername, ehash, and id but we + * pass it in the username value as that's what the login + * rout does + */ + await this.find({ username: params.id }) + + /* + * If it does not exist, return 404 + */ + if (!this.exists) return this.setResponse(404) + + return this.setResponse200({ + result: 'success', + profile: this.asProfile(), + }) +} + /* * Loads a user from the database based on the where clause you pass it * In addition prepares it for returning the account data @@ -1118,6 +1152,25 @@ UserModel.prototype.guardedMfaUpdate = async function ({ body, user, ip }) { return this.setResponse(400, 'invalidMfaSetting') } +/* + * Returns the database record as profile data for public consumption + * + * @return {account} object - The account data as a plain object + */ +UserModel.prototype.asProfile = function () { + /* + * Nothing to do here but construct the object to return + */ + return { + id: this.record.id, + bio: this.clear.bio, + img: this.clear.img, + patron: this.record.patron, + role: this.record.role, + username: this.record.username, + } +} + /* * Returns the database record as account data for for consumption * diff --git a/sites/backend/src/routes/users.mjs b/sites/backend/src/routes/users.mjs index 4742604a1e9..2f3c73c517d 100644 --- a/sites/backend/src/routes/users.mjs +++ b/sites/backend/src/routes/users.mjs @@ -55,6 +55,9 @@ export function usersRoutes(tools) { Users.isUsernameAvailable(req, res, tools) ) + // Load a user profile + app.get('/users/:id', (req, res) => Users.profile(req, res, tools)) + /* // Remove account diff --git a/sites/org/components/github/create-showcase.mjs b/sites/org/components/github/create-showcase.mjs index cb3a19b9c4c..0d39955ba2e 100644 --- a/sites/org/components/github/create-showcase.mjs +++ b/sites/org/components/github/create-showcase.mjs @@ -21,9 +21,10 @@ import { import { Collapse } from 'shared/components/collapse.mjs' import { Tab } from 'shared/components/account/bio.mjs' import { CodeBox } from 'shared/components/code-box.mjs' -import { PostArticle } from 'site/components/mdx/posts/article.mjs' +import { PostArticle, ns as mdxNs } from 'site/components/mdx/posts/article.mjs' +import { PageLink } from 'shared/components/page-link.mjs' -export const ns = nsMerge('account', authNs) +export const ns = nsMerge('account', 'posts', authNs, mdxNs) const Title = ({ children }) => (

{children}

@@ -39,11 +40,24 @@ const Item = ({ title, children }) => ( ) +const dataAsMd = ({ title, maker, caption, intro, designs }) => `--- +title: "${data.title}" +maker: ${data.maker} +caption: "${data.caption}" +date: ${yyymmvv()} +intro: "${data.intro}" +designs: [${designs.map((design) => `"${design}"`).join(', ')}] +--- + +${data.body} + +` + export const CreateShowcasePost = ({ noTitle = false }) => { const { account } = useAccount() const backend = useBackend() const toast = useToast() - const { t } = useTranslation(ns) + const { t, i18n } = useTranslation(ns) const [designs, setDesigns] = useState([]) const [title, setTitle] = useState('') @@ -55,6 +69,22 @@ export const CreateShowcasePost = ({ noTitle = false }) => { const [extraImages, setExtraImages] = useState({}) const [activeTab, setActiveTab] = useState('create') + // Method that submits the PR + const submitPullRequest = async () => { + const result = await backend.createShowcasePr({ + markdown: dataAsMd({ + title, + maker: account.username, + caption, + intro, + designs, + body, + }), + slug, + language: i18n.language, + }) + } + // Shared props for tabs const tabProps = { activeTab, setActiveTab, t } @@ -72,162 +102,250 @@ export const CreateShowcasePost = ({ noTitle = false }) => { setExtraImages(newImages) } + const childProps = { + designs, + setDesigns, + title, + setTitle, + slug, + setSlug, + img, + setImg, + caption, + setCaption, + intro, + setIntro, + body, + setBody, + extraImages, + setExtraImages, + addImage, + setExtraImg, + account, + t, + } + return ( -
- {!noTitle &&

{t('showcaseNew')}

} - {t('showcaseNewInfo')} - +
+
+ +
+
+ +
+
+
{activeTab === 'create' ? ( - <> - - Designs:{' '} - {designs.map((d) => capitalize(d)).join(', ')} - - } - > - Pick one or more designs that are featured in this post. - - - - Title: {title} - - } - > - Give your post a title. A good title is more than just a few words. - - - - Slug: {slug} - - } - > - - The slug is the part of the URL that uniquely identifies the post. We can generate - one based on the title, but you can also customize it. - - - - - Main Image: {img} - - } - > - - The main image will be shown at the top of the post, and as the only image on the - showcase index page. - - - - - Main Image Caption: {caption} - - } - > - - The caption is the text that goes under the main image. Can include - copyrights/credits. Markdown is allowed. - - - - - Intro: {intro} - - } - > - - A brief paragraph that will be shown on post previews on social media and so on. - - - - - Additional Images: {Object.keys(extraImages).length} - - } - > - {img ? ( - <> - Here you can add any images you want to include in the post body. - {Object.keys(extraImages).map((key) => { - const markup = - '![The image alt goes here](' + - cloudflareImageUrl({ id: extraImages[key], variant: 'public' }) + - ' "The image caption/title goes here")' - return ( - <> - setExtraImg(key, img)} - type="showcase" - subId={key} - img={extraImages[key]} - slug={slug} - /> - {extraImages[key] && ( - <> -

To include this image in your post, use this markdown snippet:

- - - )} - - ) - })} - - - ) : ( - - Please add a main image first - - )} -
- - Post body: {body.slice(0, 30) + '...'} - - } - > - The actual post body. Supports Markdown. - - - + ) : ( - <> -

{title}

- - + + )} +
+
+ {!(title && slug && img && designs.length > 0) && ( + +
You are missing the following:
+
    + {designs.length < 1 &&
  • Design
  • } + {!title &&
  • Title
  • } + {!slug &&
  • Slug
  • } + {!img &&
  • Main Image
  • } +
+
+ )} + + {!account.github && ( + +
+ Optional: Are you on GitHub? +
+

+ If you configure your GitHub username{' '} + , we will credit these + changes to you. +

+
)}
) } + +const ShowcasePreview = ({ designs, title, img, caption, intro, body, account }) => ( + <> +

{title}

+ + +) + +const ShowcaseEditor = ({ + designs, + setDesigns, + title, + setTitle, + slug, + setSlug, + img, + setImg, + caption, + setCaption, + intro, + setIntro, + body, + setBody, + extraImages, + setExtraImages, + addImage, + setExtraImg, + t, +}) => ( + <> +

Create a new showcase post

+ {t('showcaseNewInfo')} + + Designs:{' '} + {designs.map((d) => capitalize(d)).join(', ')} + + } + > + Pick one or more designs that are featured in this post. + + + + Title: {title} + + } + > + Give your post a title. A good title is more than just a few words. + + + + Slug: {slug} + + } + > + + The slug is the part of the URL that uniquely identifies the post. We can generate one based + on the title, but you can also customize it. + + + + + Main Image: {img} + + } + > + + The main image will be shown at the top of the post, and as the only image on the showcase + index page. + + + + + Main Image Caption: {caption} + + } + > + + The caption is the text that goes under the main image. Can include copyrights/credits. + Markdown is allowed. + + + + + Intro: {intro} + + } + > + A brief paragraph that will be shown on post previews on social media and so on. + + + + Additional Images: {Object.keys(extraImages).length} + + } + > + {img ? ( + <> + Here you can add any images you want to include in the post body. + {Object.keys(extraImages).map((key) => { + const markup = + '![The image alt goes here](' + + cloudflareImageUrl({ id: extraImages[key], variant: 'public' }) + + ' "The image caption/title goes here")' + return ( + <> + setExtraImg(key, img)} + type="showcase" + subId={key} + img={extraImages[key]} + slug={slug} + /> + {extraImages[key] && ( + <> +

To include this image in your post, use this markdown snippet:

+ + + )} + + ) + })} + + + ) : ( + + Please add a main image first + + )} +
+ + Post body: {body.slice(0, 30) + '...'} + + } + > + The actual post body. Supports Markdown. + + + +) diff --git a/sites/org/components/github/inputs.mjs b/sites/org/components/github/inputs.mjs index cfe8e3af7f9..a09b990fe9d 100644 --- a/sites/org/components/github/inputs.mjs +++ b/sites/org/components/github/inputs.mjs @@ -8,6 +8,7 @@ import { useToast } from 'shared/hooks/use-toast.mjs' import { useDropzone } from 'react-dropzone' import { Popout } from 'shared/components/popout/index.mjs' import { Loading } from 'shared/components/spinner.mjs' +import { DownloadIcon } from 'shared/components/icons.mjs' export const ns = ['account'] @@ -69,6 +70,7 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId const toast = useToast() const { t } = useTranslation(ns) const [uploading, setUploading] = useState(false) + const [url, setUrl] = useState('') const onDrop = useCallback( (acceptedFiles) => { @@ -84,6 +86,13 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId [slug] ) + const imageFromUrl = async () => { + setUploading(true) + const result = await backend.uploadImage({ type, subId, slug, url }) + setUploading(false) + if (result.success) setImg(result.data.imgId) + } + const { getRootProps, getInputProps } = useDropzone({ onDrop }) const removeImage = async () => { @@ -134,7 +143,7 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId {...getRootProps()} className={` flex rounded-lg w-full flex-col items-center justify-center - lg:h-64 lg:border-4 lg:border-secondary lg:border-dashed + lg:p-6 lg:border-4 lg:border-secondary lg:border-dashed `} > @@ -142,6 +151,19 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId

{t('or')}

+

{t('or')}

+
+ setUrl(evt.target.value)} + /> + +
) } diff --git a/sites/org/components/mdx/posts/article.mjs b/sites/org/components/mdx/posts/article.mjs index ad2fa2c3f19..cab399f5023 100644 --- a/sites/org/components/mdx/posts/article.mjs +++ b/sites/org/components/mdx/posts/article.mjs @@ -2,13 +2,15 @@ import { PageLink } from 'shared/components/page-link.mjs' import { Lightbox } from 'shared/components/lightbox.mjs' import { ImageWrapper } from 'shared/components/wrappers/img.mjs' import { Author } from './author.mjs' -import { TimeAgo } from 'shared/components/mdx/meta.mjs' +import { TimeAgo, ns as timeagoNs } from 'shared/components/timeago/index.mjs' import { useTranslation } from 'next-i18next' import { MdxWrapper } from 'shared/components/wrappers/mdx.mjs' import { cloudflareImageUrl } from 'shared/utils.mjs' import Markdown from 'react-markdown' +import { nsMerge, capitalize } from 'shared/utils.mjs' +import { Tag } from 'shared/components/tag.mjs' -export const ns = ['common', 'posts'] +export const ns = nsMerge('common', 'posts', timeagoNs) export const PostArticle = (props) => { const { t } = useTranslation('common') @@ -28,16 +30,18 @@ const PostWrapper = ({ children }) =>
const PostMeta = ({ frontmatter, t }) => (
- [{frontmatter.date}] +
{frontmatter.designs?.map((design) => ( - + > + {design} + ))}
diff --git a/sites/org/components/mdx/posts/author.mjs b/sites/org/components/mdx/posts/author.mjs index 16da37a1d88..f071323b55b 100644 --- a/sites/org/components/mdx/posts/author.mjs +++ b/sites/org/components/mdx/posts/author.mjs @@ -1,25 +1,55 @@ +import { useState, useEffect } from 'react' +import { useBackend } from 'shared/hooks/use-backend.mjs' import { useTranslation } from 'next-i18next' +import { cloudflareImageUrl } from 'shared/utils.mjs' +import { Loading } from 'shared/components/spinner.mjs' +import { Popout } from 'shared/components/popout/index.mjs' +import Markdown from 'react-markdown' export const Author = ({ author = '' }) => { const { t } = useTranslation(['posts']) + const backend = useBackend() + + const [profile, setProfile] = useState(null) + + useEffect(() => { + const loadAuthor = async () => { + const result = await backend.getProfile(author) + if (result.success && result.data.profile) setProfile(result.data.profile) + else setProfile(false) + } + if (!profile) loadAuthor() + }, [author]) + + if (profile === null) return + if (profile === false) + return ( + +
Unable to load author profile
+

Please report this (FIXME: Add reporting capabilities)

+
+ ) + + const img = cloudflareImageUrl({ id: profile.img, variant: 'sq500' }) + return (
-
+
-
- {/* + {author}*/} + src={img} + alt={profile.username} + />
{

-

FIXME: implement author bios
+
+ {profile.bio} +
) diff --git a/sites/org/components/mdx/posts/en.yaml b/sites/org/components/mdx/posts/en.yaml new file mode 100644 index 00000000000..1636839e2d0 --- /dev/null +++ b/sites/org/components/mdx/posts/en.yaml @@ -0,0 +1,4 @@ +xMadeThis: "{x} made this" +xWroteThis: "{x} wrote this" +by: By + diff --git a/sites/org/pages/new/showcase.mjs b/sites/org/pages/new/showcase.mjs index e69a5492a1d..30556065101 100644 --- a/sites/org/pages/new/showcase.mjs +++ b/sites/org/pages/new/showcase.mjs @@ -1,15 +1,17 @@ // Dependencies import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { nsMerge } from 'shared/utils.mjs' // Hooks import { useTranslation } from 'next-i18next' // Components import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs' import { ns as apikeysNs } from 'shared/components/account/apikeys.mjs' -import { CreateShowcasePost } from 'site/components/github/create-showcase.mjs' +import { CreateShowcasePost, ns as createNs } from 'site/components/github/create-showcase.mjs' +import { BareLayout } from 'site/components/layouts/bare.mjs' // Translation namespaces used on this page -const namespaces = [...new Set([...apikeysNs, ...authNs, ...pageNs])] +const namespaces = nsMerge(createNs, authNs, pageNs) /* * Each page MUST be wrapped in the PageWrapper component. @@ -21,8 +23,10 @@ const NewShowcasePage = ({ page }) => { const { t } = useTranslation(namespaces) return ( - - + +
+ +
) } diff --git a/sites/shared/components/mdx/meta.mjs b/sites/shared/components/mdx/meta.mjs index 5638d6a8b4d..b51e44a3d70 100644 --- a/sites/shared/components/mdx/meta.mjs +++ b/sites/shared/components/mdx/meta.mjs @@ -4,29 +4,11 @@ import { authors as allAuthors } from 'config/authors.mjs' import { docUpdates } from 'site/prebuild/doc-updates.mjs' // Components import { PageLink } from 'shared/components/page-link.mjs' -import { DateTime, Interval } from 'luxon' +import { TimeAgo } from 'shared/components/timeago/index.mjs' // Hooks import { useTranslation } from 'next-i18next' import { EditIcon } from 'shared/components/icons.mjs' -export const TimeAgo = ({ date, t }) => { - const i = Interval.fromDateTimes(DateTime.fromISO(date), DateTime.now()) - .toDuration(['hours', 'days', 'months', 'years']) - .toObject() - let ago = '' - if (i.years < 1 && i.months < 1) { - if (Math.floor(i.days) === 1) ago += `${t('oneDay')}` - else if (Math.floor(i.days) === 0) ago += `${t('lessThanADay')}` - } else { - if (i.years === 1) ago += `${i.years} ${t('year')}, ` - else if (i.years > 1) ago += `${i.years} ${t('years')}, ` - if (i.months === 1) ago += `${i.months} ${t('month')}` - else if (i.months > 1) ago += `${i.months} ${t('months')}` - } - - return `${ago} ${t('ago')}` -} - const PersonList = ({ list }) => list ? (
    @@ -66,7 +48,7 @@ const CreditsList = ({ updates, frontmatter, locale, t }) => ( )}
  • - {t('lastUpdated')}: + {t('lastUpdated')}:
) diff --git a/sites/shared/components/timeago/en.yaml b/sites/shared/components/timeago/en.yaml new file mode 100644 index 00000000000..de1bef8563d --- /dev/null +++ b/sites/shared/components/timeago/en.yaml @@ -0,0 +1,11 @@ +hour: hour +hours: hours +day: day +days: days +month: month +months: months +year: year +years: years +oneDay: one day +lessThanADay: less than a day +ago: ago diff --git a/sites/shared/components/timeago/index.mjs b/sites/shared/components/timeago/index.mjs new file mode 100644 index 00000000000..65c6cc26fe5 --- /dev/null +++ b/sites/shared/components/timeago/index.mjs @@ -0,0 +1,28 @@ +import { useTranslation } from 'next-i18next' +import { DateTime, Interval } from 'luxon' +import { capitalize } from 'shared/utils.mjs' + +export const ns = ['timeago'] + +export const TimeAgo = ({ date }) => { + const { t } = useTranslation('timeago') + const i = Interval.fromDateTimes(DateTime.fromISO(date), DateTime.now()) + .toDuration(['hours', 'days', 'months', 'years']) + .toObject() + let ago = '' + if (i.years < 1 && i.months < 1 && i.days < 1) { + if (Math.round(i.hours) === 1 || Math.floor(i.hours) === 1) ago += `${t('oneHour')}` + else if (Math.floor(i.hours) === 0) ago += `${t('lessThanAnHour')}` + else ago += `${Math.floor(i.hours)} ${t('hours')}` + } else if (i.years < 1 && i.months < 1) { + if (Math.floor(i.days) === 1) ago += `${t('oneDay')}` + else if (Math.floor(i.days) === 0) ago += `${t('lessThanADay')}` + } else { + if (i.years === 1) ago += `${i.years} ${t('year')}, ` + else if (i.years > 1) ago += `${i.years} ${t('years')}, ` + if (i.months === 1) ago += `${i.months} ${t('month')}` + else if (i.months > 1) ago += `${i.months} ${t('months')}` + } + + return capitalize(`${ago} ${t('ago')}`) +} diff --git a/sites/shared/config/tailwind-force.html b/sites/shared/config/tailwind-force.html index ab5340cdca2..f59f1dc479f 100644 --- a/sites/shared/config/tailwind-force.html +++ b/sites/shared/config/tailwind-force.html @@ -94,6 +94,10 @@ + + + +
diff --git a/sites/shared/hooks/use-backend.mjs b/sites/shared/hooks/use-backend.mjs index a4de89d8361..f1a0f998370 100644 --- a/sites/shared/hooks/use-backend.mjs +++ b/sites/shared/hooks/use-backend.mjs @@ -163,6 +163,14 @@ Backend.prototype.disableMfa = async function (data) { Backend.prototype.reloadAccount = async function () { return responseHandler(await await api.get(`/whoami/jwt`, this.auth)) } + +/* + * Load user profile + */ +Backend.prototype.getProfile = async function (uid) { + return responseHandler(await await api.get(`/users/${uid}`)) +} + /* * Create API key */ @@ -290,6 +298,12 @@ Backend.prototype.createIssue = async function (data) { return responseHandler(await api.post(`/issues`, data), 201) } +/* + * Create showcase Pull Request + */ +Backend.prototype.createShowcasePr = async function (data) { + return responseHandler(await api.post(`/flows/pr/showcase`, data, this.auth)) +} /* * Send translation invite */