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,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 (
<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' ? (
<>
<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>
</>
<ShowcaseEditor {...childProps} />
) : (
<>
<h1>{title}</h1>
<PostArticle
frontmatter={{
title,
maker: account.username,
date: yyyymmdd(),
caption,
intro,
}}
imgId={img}
body={body}
/>
</>
<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>
<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 { 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')}>
<CreateShowcasePost noTitle />
<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
*/