wip(org): Work on showcase editor
This commit is contained in:
parent
5e79eec9af
commit
70e636ce52
15 changed files with 492 additions and 201 deletions
|
@ -105,6 +105,18 @@ UsersController.prototype.updateMfa = async (req, res, tools) => {
|
||||||
return User.sendResponse(res)
|
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
|
* Checks whether a submitted username is available
|
||||||
*
|
*
|
||||||
|
|
|
@ -133,9 +133,9 @@ FlowModel.prototype.uploadImage = async function ({ body, user }) {
|
||||||
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
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?
|
* Is type set and valid?
|
||||||
|
@ -156,21 +156,21 @@ FlowModel.prototype.uploadImage = async function ({ body, user }) {
|
||||||
if (!body.slug) return this.setResponse(400, 'slugMissing')
|
if (!body.slug) return this.setResponse(400, 'slugMissing')
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Upload the image
|
* Prepare data for uploading the image
|
||||||
*/
|
*/
|
||||||
const id = `${body.type}-${body.slug}${
|
const data = {
|
||||||
body.subId && body.subId !== 'main' ? '-' + body.subId : ''
|
id: `${body.type}-${body.slug}${body.subId && body.subId !== 'main' ? '-' + body.subId : ''}`,
|
||||||
}`
|
metadata: { uploadedBy: user.uid },
|
||||||
const b64 = body.img
|
}
|
||||||
const 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.
|
* You need to be a curator to overwrite (replace) an image.
|
||||||
* Regular users can only update new images, not overwrite images.
|
* Regular users can only update new images, not overwrite images.
|
||||||
* If not, any user could overwrite any showcase image.
|
* If not, any user could overwrite any showcase image.
|
||||||
*/
|
*/
|
||||||
const imgId = this.rbac.curator(user)
|
const imgId = this.rbac.curator(user) ? await replaceImage(data) : await ensureImage(data)
|
||||||
? await replaceImage({ b64, id, metadata })
|
|
||||||
: await ensureImage({ b64, id, metadata })
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Return 200 and the image ID
|
* Return 200 and the image ID
|
||||||
|
|
|
@ -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
|
* Loads a user from the database based on the where clause you pass it
|
||||||
* In addition prepares it for returning the account data
|
* 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')
|
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
|
* Returns the database record as account data for for consumption
|
||||||
*
|
*
|
||||||
|
|
|
@ -55,6 +55,9 @@ export function usersRoutes(tools) {
|
||||||
Users.isUsernameAvailable(req, res, tools)
|
Users.isUsernameAvailable(req, res, tools)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Load a user profile
|
||||||
|
app.get('/users/:id', (req, res) => Users.profile(req, res, tools))
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
// Remove account
|
// Remove account
|
||||||
|
|
|
@ -21,9 +21,10 @@ import {
|
||||||
import { Collapse } from 'shared/components/collapse.mjs'
|
import { Collapse } from 'shared/components/collapse.mjs'
|
||||||
import { Tab } from 'shared/components/account/bio.mjs'
|
import { Tab } from 'shared/components/account/bio.mjs'
|
||||||
import { CodeBox } from 'shared/components/code-box.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 }) => (
|
const Title = ({ children }) => (
|
||||||
<h3 className="flex flex-row flex-wrap items-end justify-between">{children}</h3>
|
<h3 className="flex flex-row flex-wrap items-end justify-between">{children}</h3>
|
||||||
|
@ -39,11 +40,24 @@ const Item = ({ title, children }) => (
|
||||||
</div>
|
</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 }) => {
|
export const CreateShowcasePost = ({ noTitle = false }) => {
|
||||||
const { account } = useAccount()
|
const { account } = useAccount()
|
||||||
const backend = useBackend()
|
const backend = useBackend()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation(ns)
|
const { t, i18n } = useTranslation(ns)
|
||||||
|
|
||||||
const [designs, setDesigns] = useState([])
|
const [designs, setDesigns] = useState([])
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
@ -55,6 +69,22 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
||||||
const [extraImages, setExtraImages] = useState({})
|
const [extraImages, setExtraImages] = useState({})
|
||||||
const [activeTab, setActiveTab] = useState('create')
|
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
|
// Shared props for tabs
|
||||||
const tabProps = { activeTab, setActiveTab, t }
|
const tabProps = { activeTab, setActiveTab, t }
|
||||||
|
|
||||||
|
@ -72,162 +102,250 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
||||||
setExtraImages(newImages)
|
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 (
|
return (
|
||||||
<AuthWrapper>
|
<AuthWrapper>
|
||||||
<div className="max-w-2xl">
|
<div className="hidden md:grid md:grid-cols-2 md:gap-4">
|
||||||
{!noTitle && <h1>{t('showcaseNew')}</h1>}
|
<div className="p-4 shadow rounded-lg my-8">
|
||||||
<Tip>{t('showcaseNewInfo')}</Tip>
|
<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">
|
<div className="tabs w-full">
|
||||||
<Tab id="create" {...tabProps} />
|
<Tab id="create" {...tabProps} />
|
||||||
<Tab id="preview" {...tabProps} />
|
<Tab id="preview" {...tabProps} />
|
||||||
</div>
|
</div>
|
||||||
{activeTab === 'create' ? (
|
{activeTab === 'create' ? (
|
||||||
<>
|
<ShowcaseEditor {...childProps} />
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Designs</b>:{' '}
|
|
||||||
<span className="text-sm">{designs.map((d) => capitalize(d)).join(', ')}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tip>Pick one or more designs that are featured in this post.</Tip>
|
|
||||||
<DesignPicker {...{ designs, setDesigns }} />
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Title</b>: <span className="text-sm">{title}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tip>Give your post a title. A good title is more than just a few words.</Tip>
|
|
||||||
<TitleInput {...{ title, setTitle }} />
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Slug</b>: <span className="text-sm">{slug}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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.
|
|
||||||
</Tip>
|
|
||||||
<SlugInput {...{ title, slug, setSlug }} />
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Main Image</b>: <span className="text-sm">{img}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tip>
|
|
||||||
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>
|
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Main Image Caption</b>: <span className="text-sm">{caption}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tip>
|
|
||||||
The caption is the text that goes under the main image. Can include
|
|
||||||
copyrights/credits. Markdown is allowed.
|
|
||||||
</Tip>
|
|
||||||
<CaptionInput {...{ caption, setCaption }} />
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Intro</b>: <span className="text-sm">{intro}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tip>
|
|
||||||
A brief paragraph that will be shown on post previews on social media and so on.
|
|
||||||
</Tip>
|
|
||||||
<IntroInput {...{ intro, setIntro }} />
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Additional Images</b>: {Object.keys(extraImages).length}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{img ? (
|
|
||||||
<>
|
|
||||||
<Tip>Here you can add any images you want to include in the post body.</Tip>
|
|
||||||
{Object.keys(extraImages).map((key) => {
|
|
||||||
const markup =
|
|
||||||
' +
|
|
||||||
' "The image caption/title goes here")'
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ImageInput
|
|
||||||
key={key}
|
|
||||||
setImg={(img) => setExtraImg(key, img)}
|
|
||||||
type="showcase"
|
|
||||||
subId={key}
|
|
||||||
img={extraImages[key]}
|
|
||||||
slug={slug}
|
|
||||||
/>
|
|
||||||
{extraImages[key] && (
|
|
||||||
<>
|
|
||||||
<p>To include this image in your post, use this markdown snippet:</p>
|
|
||||||
<CodeBox code={markup} title="MarkDown" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<button className="btn btn-secondary mt-2" onClick={addImage}>
|
|
||||||
Add Image
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Popout note compact>
|
|
||||||
Please add a main image first
|
|
||||||
</Popout>
|
|
||||||
)}
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<b>Post body</b>: {body.slice(0, 30) + '...'}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tip>The actual post body. Supports Markdown.</Tip>
|
|
||||||
<BodyInput {...{ body, setBody }} />
|
|
||||||
</Item>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<ShowcasePreview {...childProps} />
|
||||||
<h1>{title}</h1>
|
)}
|
||||||
<PostArticle
|
</div>
|
||||||
frontmatter={{
|
<div className="px-4 max-w-lg m-auto my-8 text-center">
|
||||||
title,
|
{!(title && slug && img && designs.length > 0) && (
|
||||||
maker: account.username,
|
<Popout note>
|
||||||
date: yyyymmdd(),
|
<h5 className="text-left">You are missing the following:</h5>
|
||||||
caption,
|
<ul className="text-left list list-inside list-disc ml-4">
|
||||||
intro,
|
{designs.length < 1 && <li>Design</li>}
|
||||||
}}
|
{!title && <li>Title</li>}
|
||||||
imgId={img}
|
{!slug && <li>Slug</li>}
|
||||||
body={body}
|
{!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>
|
</div>
|
||||||
</AuthWrapper>
|
</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>
|
||||||
|
<b>Designs</b>:{' '}
|
||||||
|
<span className="text-sm">{designs.map((d) => capitalize(d)).join(', ')}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>Pick one or more designs that are featured in this post.</Tip>
|
||||||
|
<DesignPicker {...{ designs, setDesigns }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Title</b>: <span className="text-sm">{title}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>Give your post a title. A good title is more than just a few words.</Tip>
|
||||||
|
<TitleInput {...{ title, setTitle }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Slug</b>: <span className="text-sm">{slug}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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.
|
||||||
|
</Tip>
|
||||||
|
<SlugInput {...{ title, slug, setSlug }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Main Image</b>: <span className="text-sm">{img}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>
|
||||||
|
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>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Main Image Caption</b>: <span className="text-sm">{caption}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>
|
||||||
|
The caption is the text that goes under the main image. Can include copyrights/credits.
|
||||||
|
Markdown is allowed.
|
||||||
|
</Tip>
|
||||||
|
<CaptionInput {...{ caption, setCaption }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Intro</b>: <span className="text-sm">{intro}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>A brief paragraph that will be shown on post previews on social media and so on.</Tip>
|
||||||
|
<IntroInput {...{ intro, setIntro }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Additional Images</b>: {Object.keys(extraImages).length}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{img ? (
|
||||||
|
<>
|
||||||
|
<Tip>Here you can add any images you want to include in the post body.</Tip>
|
||||||
|
{Object.keys(extraImages).map((key) => {
|
||||||
|
const markup =
|
||||||
|
' +
|
||||||
|
' "The image caption/title goes here")'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ImageInput
|
||||||
|
key={key}
|
||||||
|
setImg={(img) => setExtraImg(key, img)}
|
||||||
|
type="showcase"
|
||||||
|
subId={key}
|
||||||
|
img={extraImages[key]}
|
||||||
|
slug={slug}
|
||||||
|
/>
|
||||||
|
{extraImages[key] && (
|
||||||
|
<>
|
||||||
|
<p>To include this image in your post, use this markdown snippet:</p>
|
||||||
|
<CodeBox code={markup} title="MarkDown" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button className="btn btn-secondary mt-2" onClick={addImage}>
|
||||||
|
Add Image
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Popout note compact>
|
||||||
|
Please add a main image first
|
||||||
|
</Popout>
|
||||||
|
)}
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Post body</b>: {body.slice(0, 30) + '...'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>The actual post body. Supports Markdown.</Tip>
|
||||||
|
<BodyInput {...{ body, setBody }} />
|
||||||
|
</Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { useToast } from 'shared/hooks/use-toast.mjs'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
import { Popout } from 'shared/components/popout/index.mjs'
|
||||||
import { Loading } from 'shared/components/spinner.mjs'
|
import { Loading } from 'shared/components/spinner.mjs'
|
||||||
|
import { DownloadIcon } from 'shared/components/icons.mjs'
|
||||||
|
|
||||||
export const ns = ['account']
|
export const ns = ['account']
|
||||||
|
|
||||||
|
@ -69,6 +70,7 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation(ns)
|
const { t } = useTranslation(ns)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles) => {
|
(acceptedFiles) => {
|
||||||
|
@ -84,6 +86,13 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId
|
||||||
[slug]
|
[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 { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||||
|
|
||||||
const removeImage = async () => {
|
const removeImage = async () => {
|
||||||
|
@ -134,7 +143,7 @@ export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={`
|
className={`
|
||||||
flex rounded-lg w-full flex-col items-center justify-center
|
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()} />
|
<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>
|
<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>
|
<button className={`btn btn-secondary btn-outline mt-4 px-8`}>{t('imgSelectImage')}</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,15 @@ import { PageLink } from 'shared/components/page-link.mjs'
|
||||||
import { Lightbox } from 'shared/components/lightbox.mjs'
|
import { Lightbox } from 'shared/components/lightbox.mjs'
|
||||||
import { ImageWrapper } from 'shared/components/wrappers/img.mjs'
|
import { ImageWrapper } from 'shared/components/wrappers/img.mjs'
|
||||||
import { Author } from './author.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 { useTranslation } from 'next-i18next'
|
||||||
import { MdxWrapper } from 'shared/components/wrappers/mdx.mjs'
|
import { MdxWrapper } from 'shared/components/wrappers/mdx.mjs'
|
||||||
import { cloudflareImageUrl } from 'shared/utils.mjs'
|
import { cloudflareImageUrl } from 'shared/utils.mjs'
|
||||||
import Markdown from 'react-markdown'
|
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) => {
|
export const PostArticle = (props) => {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
@ -28,16 +30,18 @@ const PostWrapper = ({ children }) => <article className="mb-12 px-8 max-w-7xl">
|
||||||
const PostMeta = ({ frontmatter, t }) => (
|
const PostMeta = ({ frontmatter, t }) => (
|
||||||
<div className="flex flex-row justify-between text-sm mb-1 mt-2">
|
<div className="flex flex-row justify-between text-sm mb-1 mt-2">
|
||||||
<div>
|
<div>
|
||||||
<TimeAgo date={frontmatter.date} t={t} /> [{frontmatter.date}]
|
<TimeAgo date={frontmatter.date} t={t} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{frontmatter.designs?.map((design) => (
|
{frontmatter.designs?.map((design) => (
|
||||||
<PageLink
|
<Tag
|
||||||
href={`/showcase/designs/${design}`}
|
href={`/showcase/designs/${design}`}
|
||||||
txt={design}
|
color="primary"
|
||||||
|
hoverColor="secondary"
|
||||||
key={design}
|
key={design}
|
||||||
className="px-2 capitalize"
|
>
|
||||||
/>
|
{design}
|
||||||
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -1,25 +1,55 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
import { useTranslation } from 'next-i18next'
|
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 = '' }) => {
|
export const Author = ({ author = '' }) => {
|
||||||
const { t } = useTranslation(['posts'])
|
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 (
|
return (
|
||||||
<div id="author" className="flex flex-col lg:flex-row m-auto p-2 items-center">
|
<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
|
<div
|
||||||
className={`
|
className={`
|
||||||
w-lg bg-cover bg-center rounded-full aspect-square
|
w-lg bg-cover bg-center rounded-full aspect-square
|
||||||
hidden lg:block
|
hidden lg:block
|
||||||
`}
|
`}
|
||||||
// style={{ backgroundImage: `url(${author.image})` }}
|
style={{ backgroundImage: `url(${img})` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="theme-gradient p-2 rounded-full aspect-square w-40 h-40 lg:hidden m-auto">
|
<div className="rounded-full aspect-square w-40 h-40 lg:hidden m-auto">
|
||||||
{/*<img
|
<img
|
||||||
className={`block w-full h-full mx-auto rounded-full`}
|
className={`block w-full h-full mx-auto rounded-full`}
|
||||||
src={author.image}
|
src={img}
|
||||||
alt={author}
|
alt={profile.username}
|
||||||
/>*/}
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
|
@ -30,10 +60,12 @@ export const Author = ({ author = '' }) => {
|
||||||
<p
|
<p
|
||||||
className="text-xl"
|
className="text-xl"
|
||||||
dangerouslySetInnerHTML={{
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
4
sites/org/components/mdx/posts/en.yaml
Normal file
4
sites/org/components/mdx/posts/en.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
xMadeThis: "<strong>{x}</strong> made this"
|
||||||
|
xWroteThis: "<strong>{x}</strong> wrote this"
|
||||||
|
by: By
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
// Dependencies
|
// Dependencies
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||||
|
import { nsMerge } from 'shared/utils.mjs'
|
||||||
// Hooks
|
// Hooks
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
// Components
|
// Components
|
||||||
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
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 authNs } from 'shared/components/wrappers/auth/index.mjs'
|
||||||
import { ns as apikeysNs } from 'shared/components/account/apikeys.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
|
// 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.
|
* Each page MUST be wrapped in the PageWrapper component.
|
||||||
|
@ -21,8 +23,10 @@ const NewShowcasePage = ({ page }) => {
|
||||||
const { t } = useTranslation(namespaces)
|
const { t } = useTranslation(namespaces)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper {...page} title={t('showcaseNew')}>
|
<PageWrapper {...page} title={t('showcaseNew')} layout={BareLayout}>
|
||||||
<CreateShowcasePost noTitle />
|
<div className="w-full px-4 mt-8">
|
||||||
|
<CreateShowcasePost noTitle />
|
||||||
|
</div>
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,29 +4,11 @@ import { authors as allAuthors } from 'config/authors.mjs'
|
||||||
import { docUpdates } from 'site/prebuild/doc-updates.mjs'
|
import { docUpdates } from 'site/prebuild/doc-updates.mjs'
|
||||||
// Components
|
// Components
|
||||||
import { PageLink } from 'shared/components/page-link.mjs'
|
import { PageLink } from 'shared/components/page-link.mjs'
|
||||||
import { DateTime, Interval } from 'luxon'
|
import { TimeAgo } from 'shared/components/timeago/index.mjs'
|
||||||
// Hooks
|
// Hooks
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { EditIcon } from 'shared/components/icons.mjs'
|
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 }) =>
|
const PersonList = ({ list }) =>
|
||||||
list ? (
|
list ? (
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -66,7 +48,7 @@ const CreditsList = ({ updates, frontmatter, locale, t }) => (
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
<li className="list-none mt-2">
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
|
|
11
sites/shared/components/timeago/en.yaml
Normal file
11
sites/shared/components/timeago/en.yaml
Normal 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
|
28
sites/shared/components/timeago/index.mjs
Normal file
28
sites/shared/components/timeago/index.mjs
Normal 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')}`)
|
||||||
|
}
|
|
@ -94,6 +94,10 @@
|
||||||
<badge class="badge badge-info hover:badge-info" />
|
<badge class="badge badge-info hover:badge-info" />
|
||||||
<badge class="badge badge-ghost hover:badge-ghost" />
|
<badge class="badge badge-ghost hover:badge-ghost" />
|
||||||
<badge class="badge badge-outline hover:badge-outline" />
|
<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 -->
|
<!-- choice button colors -->
|
||||||
<div className="hover:bg-accent hover:bg-opacity-20 hover:border-accent"></div>
|
<div className="hover:bg-accent hover:bg-opacity-20 hover:border-accent"></div>
|
||||||
|
|
|
@ -163,6 +163,14 @@ Backend.prototype.disableMfa = async function (data) {
|
||||||
Backend.prototype.reloadAccount = async function () {
|
Backend.prototype.reloadAccount = async function () {
|
||||||
return responseHandler(await await api.get(`/whoami/jwt`, this.auth))
|
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
|
* Create API key
|
||||||
*/
|
*/
|
||||||
|
@ -290,6 +298,12 @@ Backend.prototype.createIssue = async function (data) {
|
||||||
return responseHandler(await api.post(`/issues`, data), 201)
|
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
|
* Send translation invite
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue