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)
|
||||
}
|
||||
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,9 +21,10 @@ import {
|
|||
import { Collapse } from 'shared/components/collapse.mjs'
|
||||
import { Tab } from 'shared/components/account/bio.mjs'
|
||||
import { CodeBox } from 'shared/components/code-box.mjs'
|
||||
import { PostArticle } from 'site/components/mdx/posts/article.mjs'
|
||||
import { PostArticle, ns as mdxNs } from 'site/components/mdx/posts/article.mjs'
|
||||
import { PageLink } from 'shared/components/page-link.mjs'
|
||||
|
||||
export const ns = nsMerge('account', authNs)
|
||||
export const ns = nsMerge('account', 'posts', authNs, mdxNs)
|
||||
|
||||
const Title = ({ children }) => (
|
||||
<h3 className="flex flex-row flex-wrap items-end justify-between">{children}</h3>
|
||||
|
@ -39,11 +40,24 @@ const Item = ({ title, children }) => (
|
|||
</div>
|
||||
)
|
||||
|
||||
const dataAsMd = ({ title, maker, caption, intro, designs }) => `---
|
||||
title: "${data.title}"
|
||||
maker: ${data.maker}
|
||||
caption: "${data.caption}"
|
||||
date: ${yyymmvv()}
|
||||
intro: "${data.intro}"
|
||||
designs: [${designs.map((design) => `"${design}"`).join(', ')}]
|
||||
---
|
||||
|
||||
${data.body}
|
||||
|
||||
`
|
||||
|
||||
export const CreateShowcasePost = ({ noTitle = false }) => {
|
||||
const { account } = useAccount()
|
||||
const backend = useBackend()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation(ns)
|
||||
const { t, i18n } = useTranslation(ns)
|
||||
|
||||
const [designs, setDesigns] = useState([])
|
||||
const [title, setTitle] = useState('')
|
||||
|
@ -55,6 +69,22 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
|||
const [extraImages, setExtraImages] = useState({})
|
||||
const [activeTab, setActiveTab] = useState('create')
|
||||
|
||||
// Method that submits the PR
|
||||
const submitPullRequest = async () => {
|
||||
const result = await backend.createShowcasePr({
|
||||
markdown: dataAsMd({
|
||||
title,
|
||||
maker: account.username,
|
||||
caption,
|
||||
intro,
|
||||
designs,
|
||||
body,
|
||||
}),
|
||||
slug,
|
||||
language: i18n.language,
|
||||
})
|
||||
}
|
||||
|
||||
// Shared props for tabs
|
||||
const tabProps = { activeTab, setActiveTab, t }
|
||||
|
||||
|
@ -72,18 +102,127 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
|||
setExtraImages(newImages)
|
||||
}
|
||||
|
||||
const childProps = {
|
||||
designs,
|
||||
setDesigns,
|
||||
title,
|
||||
setTitle,
|
||||
slug,
|
||||
setSlug,
|
||||
img,
|
||||
setImg,
|
||||
caption,
|
||||
setCaption,
|
||||
intro,
|
||||
setIntro,
|
||||
body,
|
||||
setBody,
|
||||
extraImages,
|
||||
setExtraImages,
|
||||
addImage,
|
||||
setExtraImg,
|
||||
account,
|
||||
t,
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<div className="max-w-2xl">
|
||||
{!noTitle && <h1>{t('showcaseNew')}</h1>}
|
||||
<Tip>{t('showcaseNewInfo')}</Tip>
|
||||
|
||||
<div className="hidden md:grid md:grid-cols-2 md:gap-4">
|
||||
<div className="p-4 shadow rounded-lg my-8">
|
||||
<ShowcaseEditor {...childProps} />
|
||||
</div>
|
||||
<div className="p-4 shadow rounded-lg my-8">
|
||||
<ShowcasePreview {...childProps} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="block md:hidden px-4">
|
||||
<div className="tabs w-full">
|
||||
<Tab id="create" {...tabProps} />
|
||||
<Tab id="preview" {...tabProps} />
|
||||
</div>
|
||||
{activeTab === 'create' ? (
|
||||
<ShowcaseEditor {...childProps} />
|
||||
) : (
|
||||
<ShowcasePreview {...childProps} />
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 max-w-lg m-auto my-8 text-center">
|
||||
{!(title && slug && img && designs.length > 0) && (
|
||||
<Popout note>
|
||||
<h5 className="text-left">You are missing the following:</h5>
|
||||
<ul className="text-left list list-inside list-disc ml-4">
|
||||
{designs.length < 1 && <li>Design</li>}
|
||||
{!title && <li>Title</li>}
|
||||
{!slug && <li>Slug</li>}
|
||||
{!img && <li>Main Image</li>}
|
||||
</ul>
|
||||
</Popout>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-lg btn-primary"
|
||||
disabled={!(title && slug && img && designs.length > 0)}
|
||||
>
|
||||
Submit Showcase Post
|
||||
</button>
|
||||
{!account.github && (
|
||||
<Popout tip>
|
||||
<h5 className="text-left">
|
||||
<small>Optional:</small> Are you on GitHub?
|
||||
</h5>
|
||||
<p className="text-left">
|
||||
If you configure your GitHub username{' '}
|
||||
<PageLink href="/account/github" txt="in your account" />, we will credit these
|
||||
changes to you.
|
||||
</p>
|
||||
</Popout>
|
||||
)}
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ShowcasePreview = ({ designs, title, img, caption, intro, body, account }) => (
|
||||
<>
|
||||
<h1>{title}</h1>
|
||||
<PostArticle
|
||||
frontmatter={{
|
||||
title,
|
||||
designs,
|
||||
maker: account.username,
|
||||
date: yyyymmdd(),
|
||||
caption,
|
||||
intro,
|
||||
}}
|
||||
imgId={img}
|
||||
body={body}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
const ShowcaseEditor = ({
|
||||
designs,
|
||||
setDesigns,
|
||||
title,
|
||||
setTitle,
|
||||
slug,
|
||||
setSlug,
|
||||
img,
|
||||
setImg,
|
||||
caption,
|
||||
setCaption,
|
||||
intro,
|
||||
setIntro,
|
||||
body,
|
||||
setBody,
|
||||
extraImages,
|
||||
setExtraImages,
|
||||
addImage,
|
||||
setExtraImg,
|
||||
t,
|
||||
}) => (
|
||||
<>
|
||||
<h2>Create a new showcase post</h2>
|
||||
<Tip>{t('showcaseNewInfo')}</Tip>
|
||||
<Item
|
||||
title={
|
||||
<span>
|
||||
|
@ -113,8 +252,8 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
|||
}
|
||||
>
|
||||
<Tip>
|
||||
The slug is the part of the URL that uniquely identifies the post. We can generate
|
||||
one based on the title, but you can also customize it.
|
||||
The slug is the part of the URL that uniquely identifies the post. We can generate one based
|
||||
on the title, but you can also customize it.
|
||||
</Tip>
|
||||
<SlugInput {...{ title, slug, setSlug }} />
|
||||
</Item>
|
||||
|
@ -126,8 +265,8 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
|||
}
|
||||
>
|
||||
<Tip>
|
||||
The main image will be shown at the top of the post, and as the only image on the
|
||||
showcase index page.
|
||||
The main image will be shown at the top of the post, and as the only image on the showcase
|
||||
index page.
|
||||
</Tip>
|
||||
<ImageInput {...{ img, setImg, slug }} type="showcase" subId="main" />
|
||||
</Item>
|
||||
|
@ -139,8 +278,8 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
|||
}
|
||||
>
|
||||
<Tip>
|
||||
The caption is the text that goes under the main image. Can include
|
||||
copyrights/credits. Markdown is allowed.
|
||||
The caption is the text that goes under the main image. Can include copyrights/credits.
|
||||
Markdown is allowed.
|
||||
</Tip>
|
||||
<CaptionInput {...{ caption, setCaption }} />
|
||||
</Item>
|
||||
|
@ -151,9 +290,7 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
|||
</span>
|
||||
}
|
||||
>
|
||||
<Tip>
|
||||
A brief paragraph that will be shown on post previews on social media and so on.
|
||||
</Tip>
|
||||
<Tip>A brief paragraph that will be shown on post previews on social media and so on.</Tip>
|
||||
<IntroInput {...{ intro, setIntro }} />
|
||||
</Item>
|
||||
<Item
|
||||
|
@ -211,23 +348,4 @@ export const CreateShowcasePost = ({ noTitle = false }) => {
|
|||
<BodyInput {...{ body, setBody }} />
|
||||
</Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>{title}</h1>
|
||||
<PostArticle
|
||||
frontmatter={{
|
||||
title,
|
||||
maker: account.username,
|
||||
date: yyyymmdd(),
|
||||
caption,
|
||||
intro,
|
||||
}}
|
||||
imgId={img}
|
||||
body={body}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
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
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||
import { nsMerge } from 'shared/utils.mjs'
|
||||
// Hooks
|
||||
import { useTranslation } from 'next-i18next'
|
||||
// Components
|
||||
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
||||
import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
||||
import { ns as apikeysNs } from 'shared/components/account/apikeys.mjs'
|
||||
import { CreateShowcasePost } from 'site/components/github/create-showcase.mjs'
|
||||
import { CreateShowcasePost, ns as createNs } from 'site/components/github/create-showcase.mjs'
|
||||
import { BareLayout } from 'site/components/layouts/bare.mjs'
|
||||
|
||||
// Translation namespaces used on this page
|
||||
const namespaces = [...new Set([...apikeysNs, ...authNs, ...pageNs])]
|
||||
const namespaces = nsMerge(createNs, authNs, pageNs)
|
||||
|
||||
/*
|
||||
* Each page MUST be wrapped in the PageWrapper component.
|
||||
|
@ -21,8 +23,10 @@ const NewShowcasePage = ({ page }) => {
|
|||
const { t } = useTranslation(namespaces)
|
||||
|
||||
return (
|
||||
<PageWrapper {...page} title={t('showcaseNew')}>
|
||||
<PageWrapper {...page} title={t('showcaseNew')} layout={BareLayout}>
|
||||
<div className="w-full px-4 mt-8">
|
||||
<CreateShowcasePost noTitle />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
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-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>
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue