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

View file

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

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

View file

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

View file

@ -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 alt goes here](' +
cloudflareImageUrl({ id: extraImages[key], variant: 'public' }) +
' "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 alt goes here](' +
cloudflareImageUrl({ id: extraImages[key], variant: 'public' }) +
' "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>
</>
)

View file

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

View file

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

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 { 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>
) )

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 // 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>
) )
} }

View file

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

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

View file

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