1
0
Fork 0

wip(org): Work on showcase editor

This commit is contained in:
Joost De Cock 2023-08-16 15:54:32 +02:00
parent 5e79eec9af
commit 70e636ce52
15 changed files with 492 additions and 201 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }) => (
<h3 className="flex flex-row flex-wrap items-end justify-between">{children}</h3>
@ -39,11 +40,24 @@ const Item = ({ title, children }) => (
</div>
)
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,18 +102,127 @@ 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 (
<AuthWrapper>
<div className="max-w-2xl">
{!noTitle && <h1>{t('showcaseNew')}</h1>}
<Tip>{t('showcaseNewInfo')}</Tip>
<div className="hidden md:grid md:grid-cols-2 md:gap-4">
<div className="p-4 shadow rounded-lg my-8">
<ShowcaseEditor {...childProps} />
</div>
<div className="p-4 shadow rounded-lg my-8">
<ShowcasePreview {...childProps} />
</div>
</div>
<div className="block md:hidden px-4">
<div className="tabs w-full">
<Tab id="create" {...tabProps} />
<Tab id="preview" {...tabProps} />
</div>
{activeTab === 'create' ? (
<ShowcaseEditor {...childProps} />
) : (
<ShowcasePreview {...childProps} />
)}
</div>
<div className="px-4 max-w-lg m-auto my-8 text-center">
{!(title && slug && img && designs.length > 0) && (
<Popout note>
<h5 className="text-left">You are missing the following:</h5>
<ul className="text-left list list-inside list-disc ml-4">
{designs.length < 1 && <li>Design</li>}
{!title && <li>Title</li>}
{!slug && <li>Slug</li>}
{!img && <li>Main Image</li>}
</ul>
</Popout>
)}
<button
className="btn btn-lg btn-primary"
disabled={!(title && slug && img && designs.length > 0)}
>
Submit Showcase Post
</button>
{!account.github && (
<Popout tip>
<h5 className="text-left">
<small>Optional:</small> Are you on GitHub?
</h5>
<p className="text-left">
If you configure your GitHub username{' '}
<PageLink href="/account/github" txt="in your account" />, we will credit these
changes to you.
</p>
</Popout>
)}
</div>
</AuthWrapper>
)
}
const ShowcasePreview = ({ designs, title, img, caption, intro, body, account }) => (
<>
<h1>{title}</h1>
<PostArticle
frontmatter={{
title,
designs,
maker: account.username,
date: yyyymmdd(),
caption,
intro,
}}
imgId={img}
body={body}
/>
</>
)
const ShowcaseEditor = ({
designs,
setDesigns,
title,
setTitle,
slug,
setSlug,
img,
setImg,
caption,
setCaption,
intro,
setIntro,
body,
setBody,
extraImages,
setExtraImages,
addImage,
setExtraImg,
t,
}) => (
<>
<h2>Create a new showcase post</h2>
<Tip>{t('showcaseNewInfo')}</Tip>
<Item
title={
<span>
@ -113,8 +252,8 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
}
>
<Tip>
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.
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.
</Tip>
<SlugInput {...{ title, slug, setSlug }} />
</Item>
@ -126,8 +265,8 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
}
>
<Tip>
The main image will be shown at the top of the post, and as the only image on the
showcase index page.
The main image will be shown at the top of the post, and as the only image on the showcase
index page.
</Tip>
<ImageInput {...{ img, setImg, slug }} type="showcase" subId="main" />
</Item>
@ -139,8 +278,8 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
}
>
<Tip>
The caption is the text that goes under the main image. Can include
copyrights/credits. Markdown is allowed.
The caption is the text that goes under the main image. Can include copyrights/credits.
Markdown is allowed.
</Tip>
<CaptionInput {...{ caption, setCaption }} />
</Item>
@ -151,9 +290,7 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
</span>
}
>
<Tip>
A brief paragraph that will be shown on post previews on social media and so on.
</Tip>
<Tip>A brief paragraph that will be shown on post previews on social media and so on.</Tip>
<IntroInput {...{ intro, setIntro }} />
</Item>
<Item
@ -211,23 +348,4 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
<BodyInput {...{ body, setBody }} />
</Item>
</>
) : (
<>
<h1>{title}</h1>
<PostArticle
frontmatter={{
title,
maker: account.username,
date: yyyymmdd(),
caption,
intro,
}}
imgId={img}
body={body}
/>
</>
)}
</div>
</AuthWrapper>
)
}

View file

@ -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
`}
>
<input {...getInputProps()} />
@ -142,6 +151,19 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId
<p className="hidden lg:block p-0 my-2">{t('or')}</p>
<button className={`btn btn-secondary btn-outline mt-4 px-8`}>{t('imgSelectImage')}</button>
</div>
<p className="hidden lg:block p-0 my-2 text-center">{t('or')}</p>
<div className="flex flex-row items-center">
<input
type="url"
className="input input-secondary w-full input-bordered rounded-r-none"
placeholder="Paste an image URL here and click the download icon"
value={url}
onChange={(evt) => setUrl(evt.target.value)}
/>
<button disabled={!url} className="btn btn-secondary rounded-l-none" onClick={imageFromUrl}>
<DownloadIcon />
</button>
</div>
</div>
)
}

View file

@ -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 }) => <article className="mb-12 px-8 max-w-7xl">
const PostMeta = ({ frontmatter, t }) => (
<div className="flex flex-row justify-between text-sm mb-1 mt-2">
<div>
<TimeAgo date={frontmatter.date} t={t} /> [{frontmatter.date}]
<TimeAgo date={frontmatter.date} t={t} />
</div>
<div>
{frontmatter.designs?.map((design) => (
<PageLink
<Tag
href={`/showcase/designs/${design}`}
txt={design}
color="primary"
hoverColor="secondary"
key={design}
className="px-2 capitalize"
/>
>
{design}
</Tag>
))}
</div>
<div>

View file

@ -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 <Loading />
if (profile === false)
return (
<Popout warning>
<h5>Unable to load author profile</h5>
<p>Please report this (FIXME: Add reporting capabilities)</p>
</Popout>
)
const img = cloudflareImageUrl({ id: profile.img, variant: 'sq500' })
return (
<div id="author" className="flex flex-col lg:flex-row m-auto p-2 items-center">
<div className="theme-gradient w-40 h-40 p-2 rounded-full aspect-square hidden lg:block">
<div className="bg-base-200 shadow w-40 h-40 rounded-full aspect-square hidden lg:block">
<div
className={`
w-lg bg-cover bg-center rounded-full aspect-square
hidden lg:block
`}
// style={{ backgroundImage: `url(${author.image})` }}
style={{ backgroundImage: `url(${img})` }}
></div>
</div>
<div className="theme-gradient p-2 rounded-full aspect-square w-40 h-40 lg:hidden m-auto">
{/*<img
<div className="rounded-full aspect-square w-40 h-40 lg:hidden m-auto">
<img
className={`block w-full h-full mx-auto rounded-full`}
src={author.image}
alt={author}
/>*/}
src={img}
alt={profile.username}
/>
</div>
<div
className={`
@ -30,10 +60,12 @@ export const Author = ({ author = '' }) => {
<p
className="text-xl"
dangerouslySetInnerHTML={{
__html: t('xMadeThis', { x: author || 'FIXME: no display name' }),
__html: t('xMadeThis', { x: profile.username }),
}}
/>
<div className="prose mdx">FIXME: implement author bios</div>
<div className="prose mdx">
<Markdown>{profile.bio}</Markdown>
</div>
</div>
</div>
)

View file

@ -0,0 +1,4 @@
xMadeThis: "<strong>{x}</strong> made this"
xWroteThis: "<strong>{x}</strong> wrote this"
by: By

View file

@ -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 (
<PageWrapper {...page} title={t('showcaseNew')}>
<PageWrapper {...page} title={t('showcaseNew')} layout={BareLayout}>
<div className="w-full px-4 mt-8">
<CreateShowcasePost noTitle />
</div>
</PageWrapper>
)
}

View file

@ -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 ? (
<ul>
@ -66,7 +48,7 @@ const CreditsList = ({ updates, frontmatter, locale, t }) => (
</li>
)}
<li className="list-none mt-2">
<b className="pr-2">{t('lastUpdated')}:</b> <TimeAgo date={updates.u} t={t} />
<b className="pr-2">{t('lastUpdated')}:</b> <TimeAgo date={updates.u} />
</li>
</ul>
)

View file

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

View file

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

View file

@ -94,6 +94,10 @@
<badge class="badge badge-info hover:badge-info" />
<badge class="badge badge-ghost hover:badge-ghost" />
<badge class="badge badge-outline hover:badge-outline" />
<badge class="badge badge-primary hover:badge-primary" />
<badge class="badge badge-secondary hover:badge-secondary" />
<badge class="badge badge-accent hover:badge-accent" />
<badge class="badge badge-neutral hover:badge-neutral" />
<!-- choice button colors -->
<div className="hover:bg-accent hover:bg-opacity-20 hover:border-accent"></div>

View file

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