1
0
Fork 0

wip(org): Working on admin & content creation pages

This commit is contained in:
joostdecock 2023-08-15 17:32:47 +02:00
parent 61da3e1dab
commit 50b6747584
26 changed files with 1221 additions and 3383 deletions

View file

@ -379,6 +379,7 @@ shared:
'remark-mdx-frontmatter': '3.0.0'
'remark-smartypants': '2.0.0'
'sharp': '0.32.4'
"slugify": "^1.6.6"
# see: https://github.com/npm/cli/issues/2610#issuecomment-1295371753
'svg-to-pdfkit': 'https://git@github.com/eriese/SVG-to-PDFKit'
'tlds': '1.240.0'

View file

@ -31,3 +31,13 @@ AdminController.prototype.updateUser = async (req, res, tools) => {
return Admin.sendResponse(res)
}
/*
* Update user
*/
AdminController.prototype.impersonateUser = async (req, res, tools) => {
const Admin = new AdminModel(tools)
await Admin.impersonateUser(req)
return Admin.User.sendResponse(res)
}

View file

@ -23,3 +23,25 @@ FlowsController.prototype.sendLanguageSuggestion = async (req, res, tools) => {
return Flow.sendResponse(res)
}
/*
* Upload an image to Cloudflare
* See: https://freesewing.dev/reference/backend/api
*/
FlowsController.prototype.uploadImage = async (req, res, tools) => {
const Flow = new FlowModel(tools)
await Flow.uploadImage(req)
return Flow.sendResponse(res)
}
/*
* Remove an image from Cloudflare
* See: https://freesewing.dev/reference/backend/api
*/
FlowsController.prototype.removeImage = async (req, res, tools) => {
const Flow = new FlowModel(tools)
await Flow.removeImage(req)
return Flow.sendResponse(res)
}

View file

@ -138,3 +138,39 @@ AdminModel.prototype.updateUser = async function ({ params, body, user }) {
*/
return this.setResponse200({ user: this.User.asAccount() })
}
/*
* Impersonates a user
* This logs an admin in as another user
*
* @param {params} object - The (URL) request parameters.
* @param {body} object - The request body
* @param {user} object - The user as loaded by auth middleware
* @returns {AdminModel} object - The AdminModel
*/
AdminModel.prototype.impersonateUser = async function ({ params, body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Is id set?
*/
if (!params.id) return this.setResponse(403, 'idMissing')
/*
* Attempt to load the user from the database
*/
await this.User.read({ id: Number(params.id) })
/*
* If the user cannot be found, return 404
*/
if (!this.User.record) return this.setResponse(404)
/*
* Return 200, token, and data
*/
return this.User.signInOk()
}

View file

@ -1,5 +1,6 @@
import { i18nUrl } from '../utils/index.mjs'
import { decorateModel } from '../utils/model-decorator.mjs'
import { ensureImage, replaceImage, removeImage } from '../utils/cloudflare-images.mjs'
/*
* This model handles all flows (typically that involves sending out emails)
@ -112,3 +113,96 @@ FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) {
*/
return this.setResponse200({})
}
/*
* Upload an image to cloudflare
*
* @param {body} object - The request body
* @param {user} object - The user as loaded by auth middleware
* @returns {FlowModel} object - The FlowModel
*/
FlowModel.prototype.uploadImage = async function ({ body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Do we have a POST body?
*/
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
/*
* Is img set?
*/
if (!body.img) return this.setResponse(400, 'imgMissing')
/*
* Is type set and valid?
*/
if (!body.type) return this.setResponse(400, 'typeMissing')
if (!['blog', 'showcase'].includes(body.type)) return this.setResponse(400, 'typeInvalid')
/*
* Is subId set and valid?
*/
if (!body.subId) return this.setResponse(400, 'subIdMissing')
if (body.subId !== 'main' && ![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(Number(body.subId)))
return this.setResponse(400, 'subIdInvalid')
/*
* Is slug set?
*/
if (!body.slug) return this.setResponse(400, 'slugMissing')
/*
* Upload the image
*/
const id = `${body.type}-${body.slug}${
body.subId && body.subId !== 'main' ? '-' + body.subId : ''
}`
const b64 = body.img
const metadata = { uploadedBy: user.uid }
/*
* 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 })
/*
* Return 200 and the image ID
*/
return this.setResponse200({ imgId })
}
/*
* Remove an image from cloudflare
*
* @param {params} object - The request (URL) params
* @param {user} object - The user as loaded by auth middleware
* @returns {FlowModel} object - The FlowModel
*/
FlowModel.prototype.removeImage = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Is id set?
*/
if (!params.id) return this.setResponse(400, 'idMissing')
/*
* Remove the image
*/
const gone = await removeImage(params.id)
/*
* Return 204
*/
return gone ? this.setResponse(204) : this.setResponse(500, 'unableToRemoveImage')
}

View file

@ -486,16 +486,16 @@ const v2lut = {
'größe 42, mit brüsten': 8,
'größe 44, mit brüsten': 9,
'größe 46, mit brüsten': 10,
'grösse 28, mit brüsten': 1,
'grösse 30, mit brüsten': 2,
'grösse 32, mit brüsten': 3,
'grösse 34, mit brüsten': 4,
'grösse 36, mit brüsten': 5,
'grösse 38, mit brüsten': 6,
'grösse 40, mit brüsten': 7,
'grösse 42, mit brüsten': 8,
'grösse 44, mit brüsten': 9,
'grösse 46, mit brüsten': 10,
'maat 28, met borsten': 1,
'maat 30, met borsten': 2,
'maat 32, met borsten': 3,
'maat 34, met borsten': 4,
'maat 36, met borsten': 5,
'maat 38, met borsten': 6,
'maat 40, met borsten': 7,
'maat 42, met borsten': 8,
'maat 44, met borsten': 9,
'maat 46, met borsten': 10,
'taille 28, avec des seins': 1,
'taille 30, avec des seins': 2,
'taille 32, avec des seins': 3,

View file

@ -30,4 +30,9 @@ export function adminRoutes(tools) {
app.patch('/admin/user/:id/key', passport.authenticate(...bsc), (req, res) =>
Admin.updateUser(req, res, tools)
)
// Impersonate user
app.get('/admin/impersonate/:id/jwt', passport.authenticate(...jwt), (req, res) =>
Admin.impersonateUser(req, res, tools)
)
}

View file

@ -22,4 +22,20 @@ export function flowsRoutes(tools) {
app.post('/flows/language-suggestion/key', passport.authenticate(...bsc), (req, res) =>
Flow.sendLanguageSuggestion(req, res, tools)
)
// Upload an image
app.post('/images/jwt', passport.authenticate(...jwt), (req, res) =>
Flow.uploadImage(req, res, tools)
)
app.post('/images/key', passport.authenticate(...bsc), (req, res) =>
Flow.uploadImage(req, res, tools)
)
// Remove an image
app.delete('/images/:id/jwt', passport.authenticate(...jwt), (req, res) =>
Flow.removeImage(req, res, tools)
)
app.delete('/images/:id/key', passport.authenticate(...bsc), (req, res) =>
Flow.removeImage(req, res, tools)
)
}

View file

@ -58,6 +58,7 @@ export async function replaceImage(props, isTest = false) {
result = await axios.post(config.api, form, { headers })
} catch (err) {
console.log('Failed to replace image on cloudflare', err)
console.log(err.response.data)
}
return result.data?.result?.id ? result.data.result.id : false
@ -74,12 +75,24 @@ export async function ensureImage(props, isTest = false) {
await axios.post(config.api, form, { headers })
} catch (err) {
// It's fine
console.log(err)
}
return props.id
}
/*
* Method that removes an image fron cloudflare
*/
export async function removeImage(id) {
try {
await axios.delete(`${config.api}/${id}`, { headers })
} catch (err) {
return false
}
return true
}
/*
* Method that imports and image from URL and does not bother waiting for the answer
*/
@ -109,7 +122,14 @@ export async function importImage(props, isTest = false) {
/*
* Helper method to construct the form data for cloudflare
*/
function getFormData({ id, metadata, url = false, b64 = false, blob = false, notPublic = false }) {
function getFormData({
id,
metadata = {},
url = false,
b64 = false,
blob = false,
notPublic = false,
}) {
const form = new FormData()
form.append('id', id)
form.append('metadata', JSON.stringify(metadata))

View file

@ -36,7 +36,6 @@
"@mdx-js/runtime": "2.0.0-next.9",
"@tailwindcss/typography": "0.5.9",
"algoliasearch": "4.19.1",
"react-copy-to-clipboard": "5.1.0",
"daisyui": "3.5.1",
"echarts": "5.4.2",
"echarts-for-react": "3.0.2",
@ -47,10 +46,11 @@
"lodash.set": "4.3.2",
"luxon": "3.3.0",
"next": "13.4.13",
"react-copy-to-clipboard": "5.1.0",
"react-dropzone": "14.2.3",
"react-hot-toast": "2.4.1",
"react-hotkeys-hook": "4.4.1",
"react-instantsearch-dom": "6.40.4",
"react-hot-toast": "2.4.1",
"react-markdown": "8.0.7",
"react-swipeable": "7.0.1",
"react-timeago": "7.1.0",
@ -62,6 +62,7 @@
"remark-copy-linked-files": "git+https://git@github.com/joostdecock/remark-copy-linked-files",
"remark-gfm": "3.0.1",
"remark-mdx-frontmatter": "3.0.0",
"slugify": "^1.6.6",
"use-persisted-state": "0.3.3",
"yaml-loader": "0.8.0"
},
@ -70,8 +71,8 @@
"autoprefixer": "10.4.14",
"eslint-config-next": "13.4.13",
"js-yaml": "4.1.0",
"postcss": "8.4.27",
"playwright": "^1.32.3",
"postcss": "8.4.27",
"remark-extract-frontmatter": "3.2.0",
"remark-mdx-frontmatter": "3.0.0",
"tailwindcss": "3.3.3",

View file

@ -1,117 +1,15 @@
// Dependencies
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { nsMerge } from 'shared/utils.mjs'
import { cloudflareConfig } from 'shared/config/cloudflare.mjs'
import { formatNumber } from 'shared/utils.mjs'
// Hooks
import { useState } from 'react'
import { useTranslation } from 'next-i18next'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useAccount } from 'shared/hooks/use-account.mjs'
import { useToast } from 'shared/hooks/use-toast.mjs'
// Components
import Link from 'next/link'
import Markdown from 'react-markdown'
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
import { Popout } from 'shared/components/popout/index.mjs'
import { Collapse } from 'shared/components/collapse.mjs'
import { Spinner } from 'shared/components/spinner.mjs'
import { Json } from 'shared/components/json.mjs'
import { AccountRole } from 'shared/components/account/role.mjs'
import { AccountStatus } from 'shared/components/account/status.mjs'
const Hits = ({ results, t }) => (
<>
{results && results.username && results.username.length > 0 && (
<>
<h2>Results based on username</h2>
{results.username.map((user) => (
<User user={user} key={user.id} />
))}
</>
)}
{results && results.email && results.email.length > 0 && (
<>
<h2>Results based on E-mail address</h2>
{results.email.map((user) => (
<User user={user} key={user.id} />
))}
</>
)}
</>
)
const Row = ({ title, val }) => (
<tr className="py-1">
<td className="text-sm px-2 text-right font-bold">{title}</td>
<td className="text-sm">{val}</td>
</tr>
)
export const User = ({ user, detail = false }) => (
<div className="my-8">
<div className="flex flex-row w-full gap-4">
<div
className="w-52 h-52 bg-base-100 rounded-lg shadow shrink-0"
style={{
backgroundImage: `url(${cloudflareConfig.url}${user.img}/sq500)`,
backgroundSize: 'cover',
}}
></div>
<div className="w-full">
<h6 className="flex flex-row items-center gap-2 flex-wrap">
{user.username}
<span className="font-light">|</span>
<AccountRole role={user.role} />
<span className="font-light">|</span>
<AccountStatus status={user.status} />
<span className="font-light">|</span>
{user.id}
</h6>
<div className="flex flex-row flex-wrap gap-4 w-full">
<div className="max-w-xs">
<table>
<tbody>
<Row title="Email" val={user.email} />
<Row title="Initial" val={user.initial} />
<Row title="GitHub" val={user.github} />
<Row title="MFA" val={user.mfaEnabled ? 'Yes' : 'No'} />
<Row title="Passhash" val={user.passwordType} />
</tbody>
</table>
</div>
<div className="max-w-xs">
<table>
<tbody>
<Row title="Patron" val={user.patron} />
<Row title="Consent" val={user.consent} />
<Row title="Control" val={user.control} />
<Row title="Calls (jwt)" val={user.jwtCalls} />
<Row title="Calls (key)" val={user.keyCalls} />
</tbody>
</table>
</div>
<div className="max-w-xs flex flex-col gap-2">
<button className="btn btn-primary">Impersonate</button>
{detail ? (
<Link href={`/admin/user/${user.id}`} className="btn btn-primary btn-outline">
More options
</Link>
) : (
<Link href="/admin" className="btn btn-primary btn-outline">
Search other users
</Link>
)}
</div>
</div>
<div className="max-w-full truncate">
<Markdown>{user.bio}</Markdown>
</div>
</div>
</div>
</div>
)
import { Loading } from 'shared/components/spinner.mjs'
import { Hits } from 'shared/components/admin.mjs'
// Translation namespaces used on this page
const namespaces = nsMerge(pageNs, authNs)
@ -150,7 +48,7 @@ const AdminPage = ({ page }) => {
type="text"
placeholder="Username, ID, or E-mail address"
/>
{loading ? <Spinner /> : <Hits {...{ backend, t, results }} />}
{loading ? <Loading /> : <Hits {...{ backend, t, results }} />}
</AuthWrapper>
</PageWrapper>
)

View file

@ -1,101 +1,19 @@
// Dependencies
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { nsMerge } from 'shared/utils.mjs'
import { freeSewingConfig } from 'shared/config/freesewing.config.mjs'
// Hooks
import { useState, useEffect } from 'react'
import { useTranslation } from 'next-i18next'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useToast } from 'shared/hooks/use-toast.mjs'
// Components
import Link from 'next/link'
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
import { Json } from 'shared/components/json.mjs'
import { User } from 'site/pages/admin/index.mjs'
import { ManageUser } from 'shared/components/admin.mjs'
// Translation namespaces used on this page
const namespaces = nsMerge(pageNs, authNs)
const roles = ['user', 'curator', 'bughunter', 'support', 'admin']
const UserAdminPage = ({ page, userId }) => {
const { t } = useTranslation(namespaces)
const toast = useToast()
const backend = useBackend()
const [user, setUser] = useState({})
useEffect(() => {
const loadUser = async () => {
const result = await backend.adminLoadUser(userId)
if (result.success) setUser(result.data.user)
}
loadUser()
}, [userId])
const updateUser = async (data) => {
const result = await backend.adminUpdateUser({ id: userId, data })
if (result.success) {
toast.for.settingsSaved()
setUser(result.data.user)
} else toast.for.backendError()
}
const search = async (val) => {
setQ(val)
if (val.length < 2) return
/*
* Search backend
*/
setLoading(true)
const result = await backend.adminSearchUsers(val)
if (result.success) {
setResults(result.data.users)
}
setLoading(false)
}
return (
<PageWrapper {...page} title={user.id ? user.username : 'One moment please...'}>
<AuthWrapper requiredRole="admin">
{user.id ? <User user={user} /> : null}
<div className="flex flex-row flex-wrap gap-2 my-2">
{roles.map((role) => (
<button
key={role}
className="btn btn-primary btn-outline btn-sm"
onClick={() => updateUser({ role })}
disabled={role === user.role}
>
Assign {role} role
</button>
))}
</div>
<div className="flex flex-row flex-wrap gap-2 my-2">
{user.mfaEnabled && (
<button
className="btn btn-warning btn-outline btn-sm"
onClick={() => updateUser({ mfaEnabled: false })}
>
Disable MFA
</button>
)}
{Object.keys(freeSewingConfig.statuses).map((status) => (
<button
className="btn btn-warning btn-outline btn-sm"
onClick={() => updateUser({ status })}
disabled={Number(status) === user.status}
>
Set {freeSewingConfig.statuses[status].name.toUpperCase()} status
</button>
))}
</div>
{user.id ? <Json js={user} /> : null}
</AuthWrapper>
const UserAdminPage = ({ page, userId }) => (
<PageWrapper {...page} title={`User ${userId}`}>
<AuthWrapper requiredRole="admin">{userId ? <ManageUser userId={userId} /> : null}</AuthWrapper>
</PageWrapper>
)
}
)
export default UserAdminPage

View file

@ -6,12 +6,46 @@ import { useAccount } from 'shared/hooks/use-account.mjs'
// Components
import Link from 'next/link'
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
import { KeyIcon, MeasieIcon, DesignIcon, PageIcon, PluginIcon } from 'shared/components/icons.mjs'
import {
KeyIcon,
MeasieIcon,
DesignIcon,
PageIcon,
PluginIcon,
ShowcaseIcon,
RssIcon,
} from 'shared/components/icons.mjs'
// Translation namespaces used on this page
// Note that we include the account namespace here for the 'new' keyword
const namespaces = [...pageNs, 'account']
const Box = ({ title, Icon, description, href }) => {
const linkProps = {
href,
className:
'p-8 -ml-4 -mr-4 md:m-0 rounded-none md:rounded-xl md:shadow hover:bg-secondary bg-base-200 hover:text-secondary-content',
}
const inner = (
<>
<h4 className="flex flex-row items-start justify-between w-full m-0 p-0 text-inherit">
<span>{title}</span>
<Icon className="w-12 h-12 -mt-2" />
</h4>
<div className={`normal-case text-base font-medium text-left pt-2 text-inherit`}>
{description}
</div>
</>
)
return href.slice(0, 4) === 'http' ? (
<a {...linkProps}>{inner}</a>
) : (
<Link {...linkProps}>{inner}</Link>
)
}
/*
* Each page MUST be wrapped in the PageWrapper component.
* You also MUST spread props.page into this wrapper component
@ -25,77 +59,55 @@ const NewIndexPage = ({ page }) => {
const control = account.control ? account.control : 99
const boxClasses =
'p-8 -ml-4 -mr-4 md:m-0 bg-gradient-to-tr rounded-none md:rounded-xl md:shadow hover:from-secondary hover:to-secondary'
'p-8 -ml-4 -mr-4 md:m-0 rounded-none md:rounded-xl md:shadow hover:bg-secondary bg-base-200 hover:text-secondary-content'
return (
<PageWrapper {...page} title={t('new')}>
<div className="w-full flex flex-col md:grid md:grid-cols-2 gap-4">
<Link href="/new/pattern" className={`${boxClasses} from-accent to-primary`}>
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
<span>{t('patternNew')}</span>
<PageIcon className="w-12 h-12 text-primary-content -mt-2" />
</h4>
<div className={`normal-case text-base font-medium text-primary-content text-left pt-2`}>
{t('patternNewInfo')}
</div>
</Link>
<Link href="/new/set" className={`${boxClasses} from-secondary to-success`}>
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
<span>{t('newSet')}</span>
<MeasieIcon className="w-12 h-12 text-primary-content -mt-2" />
</h4>
<div className={`normal-case text-base font-medium text-primary-content text-left pt-2`}>
{t('setNewInfo')}
</div>
</Link>
</div>
<div className="w-full max-w-xl flex flex-col gap-4">
<h2>{t('newBasic')}</h2>
<Box
title={t('patternNew')}
Icon={PageIcon}
description={t('patternNewInfo')}
href="/new/pattern"
/>
<Box title={t('newSet')} Icon={MeasieIcon} description={t('setNewInfo')} href="/new/set" />
<h2>{t('newAdvanced')}</h2>
<Box
title={t('showcaseNew')}
Icon={ShowcaseIcon}
description={t('showcaseNewInfo')}
href="/new/showcase"
/>
<Box
title={t('blogNew')}
Icon={RssIcon}
description={t('blogNewInfo')}
href="/new/blogpost"
/>
{control > 3 ? (
<div className="w-full flex flex-col md:grid md:grid-cols-3 gap-4 mt-4">
<Link href="/new/apikey" className={`${boxClasses} from-neutral to-info`}>
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
<span>{t('newApikey')}</span>
<KeyIcon className="w-12 h-12 text-primary-content -mt-2" />
</h4>
<div
className={`normal-case text-base font-medium text-primary-content text-left pt-2`}
>
{t('keyNewInfo')}
</div>
</Link>
<a
<>
<Box
title={t('newApikey')}
Icon={KeyIcon}
description={t('keyNewInfo')}
href="/new/apikey"
/>
<Box
title={t('designNew')}
Icon={DesignIcon}
description={t('designNewInfo')}
href="https://freesewing.dev/tutorials/pattern-design"
className={`${boxClasses} from-neutral to-success`}
>
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
<span>{t('designNew')}</span>
<DesignIcon className="w-12 h-12 text-primary-content -mt-2" />
</h4>
<div
className={`normal-case text-base font-medium text-primary-content text-left pt-2`}
>
{t('designNewInfo')}
</div>
</a>
<a
/>
<Box
title={t('pluginNew')}
Icon={PluginIcon}
description={t('pluginNewInfo')}
href="https://freesewing.dev/guides/plugins"
className={`${boxClasses} from-neutral to-accent`}
>
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
<span>{t('pluginNew')}</span>
<PluginIcon className="w-12 h-12 text-primary-content -mt-2" />
</h4>
<div
className={`normal-case text-base font-medium text-primary-content text-left pt-2`}
>
{t('pluginNewInfo')}
</div>
</a>
</div>
/>
</>
) : null}
</div>
</PageWrapper>
)
}

View file

@ -0,0 +1,42 @@
// Dependencies
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
// 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 'shared/components/github/create-showcase.mjs'
// Translation namespaces used on this page
const namespaces = [...new Set([...apikeysNs, ...authNs, ...pageNs])]
/*
* Each page MUST be wrapped in the PageWrapper component.
* You also MUST spread props.page into this wrapper component
* when path and locale come from static props (as here)
* or set them manually.
*/
const NewShowcasePage = ({ page }) => {
const { t } = useTranslation(namespaces)
return (
<PageWrapper {...page} title={t('showcaseNew')}>
<CreateShowcasePost noTitle />
</PageWrapper>
)
}
export default NewShowcasePage
export async function getStaticProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, namespaces)),
page: {
locale,
path: ['new', 'showcase'],
},
},
}
}

View file

@ -190,6 +190,12 @@ designNewInfo: FreeSewing designs are small bundles of JavaScript code that gene
pluginNew: Create a new plugin
pluginNewInfo: FreeSewing's functionality can be further extended with plugins. Creating a plugin is easy, and we have a guide to take you from start to finish.
showcaseNew: Create a new showcase post
showcaseNewInfo: If you would like to share something you (or someone else) made based on our designs, you can start a draft showcase post here.
blogNew: Create a new blog post
blogNewInfo: If you would like to write on the FreeSewing blog, you can start a draft blog post here.
newBasic: The basics
newAdvanced: Go further

View file

@ -0,0 +1,200 @@
import { useState, useEffect } from 'react'
import { cloudflareConfig } from 'shared/config/cloudflare.mjs'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useAccount } from 'shared/hooks/use-account.mjs'
import { useToast } from 'shared/hooks/use-toast.mjs'
import { formatNumber } from 'shared/utils.mjs'
import Link from 'next/link'
import Markdown from 'react-markdown'
import { Popout } from 'shared/components/popout/index.mjs'
import { Collapse } from 'shared/components/collapse.mjs'
import { Spinner } from 'shared/components/spinner.mjs'
import { Json } from 'shared/components/json.mjs'
import { AccountRole } from 'shared/components/account/role.mjs'
import { AccountStatus } from 'shared/components/account/status.mjs'
import { Loading } from 'shared/components/spinner.mjs'
import { freeSewingConfig } from 'shared/config/freesewing.config.mjs'
const roles = ['user', 'curator', 'bughunter', 'support', 'admin']
export const ImpersonateButton = ({ userId }) => {
const toast = useToast()
const backend = useBackend()
const { impersonate } = useAccount()
if (!userId) return null
const impersonateUser = async () => {
const result = await backend.adminImpersonateUser(userId)
if (result.success) {
impersonate(result.data)
toast.for.settingsSaved()
} else toast.for.backendError()
}
return (
<button className="btn btn-primary" onClick={impersonateUser}>
Impersonate
</button>
)
}
export const Row = ({ title, val }) => (
<tr className="py-1">
<td className="text-sm px-2 text-right font-bold">{title}</td>
<td className="text-sm">{val}</td>
</tr>
)
export const Hits = ({ results, t }) => (
<>
{results && results.username && results.username.length > 0 && (
<>
<h2>Results based on username</h2>
{results.username.map((user) => (
<User user={user} key={user.id} />
))}
</>
)}
{results && results.email && results.email.length > 0 && (
<>
<h2>Results based on E-mail address</h2>
{results.email.map((user) => (
<User user={user} key={user.id} />
))}
</>
)}
</>
)
export const ShowUser = ({ user, button = null }) => (
<div className="flex flex-row w-full gap-4">
<div
className="w-52 h-52 bg-base-100 rounded-lg shadow shrink-0"
style={{
backgroundImage: `url(${cloudflareConfig.url}${user.img}/sq500)`,
backgroundSize: 'cover',
}}
></div>
<div className="w-full">
<h6 className="flex flex-row items-center gap-2 flex-wrap">
{user.username}
<span className="font-light">|</span>
<AccountRole role={user.role} />
<span className="font-light">|</span>
<AccountStatus status={user.status} />
<span className="font-light">|</span>
{user.id}
</h6>
<div className="flex flex-row flex-wrap gap-4 w-full">
<div className="max-w-xs">
<table>
<tbody>
<Row title="Email" val={user.email} />
<Row title="Initial" val={user.initial} />
<Row title="GitHub" val={user.github} />
<Row title="MFA" val={user.mfaEnabled ? 'Yes' : 'No'} />
<Row title="Passhash" val={user.passwordType} />
</tbody>
</table>
</div>
<div className="max-w-xs">
<table>
<tbody>
<Row title="Patron" val={user.patron} />
<Row title="Consent" val={user.consent} />
<Row title="Control" val={user.control} />
<Row title="Calls (jwt)" val={user.jwtCalls} />
<Row title="Calls (key)" val={user.keyCalls} />
</tbody>
</table>
</div>
<div className="max-w-xs flex flex-col gap-2">{button}</div>
</div>
<div className="max-w-full truncate">
<Markdown>{user.bio}</Markdown>
</div>
</div>
</div>
)
export const User = ({ user }) => (
<div className="my-8">
<ShowUser
user={user}
button={
<Link href={`/admin/user/${user.id}`} className="btn btn-primary">
Manage user
</Link>
}
/>
</div>
)
export const ManageUser = ({ userId }) => {
// Hooks
const backend = useBackend()
const toast = useToast()
// State
const [user, setUser] = useState({})
// Effect
useEffect(() => {
const loadUser = async () => {
const result = await backend.adminLoadUser(userId)
if (result.success) setUser(result.data.user)
}
loadUser()
}, [userId])
const updateUser = async (data) => {
const result = await backend.adminUpdateUser({ id: userId, data })
if (result.success) {
toast.for.settingsSaved()
setUser(result.data.user)
} else toast.for.backendError()
}
return user.id ? (
<div className="my-8">
<ShowUser user={user} button={<ImpersonateButton userId={user.id} />} />
<div className="flex flex-row flex-wrap gap-2 my-2">
{roles.map((role) => (
<button
key={role}
className="btn btn-primary btn-outline btn-sm"
onClick={() => updateUser({ role })}
disabled={role === user.role}
>
Assign {role} role
</button>
))}
</div>
<div className="flex flex-row flex-wrap gap-2 my-2">
{user.mfaEnabled && (
<button
className="btn btn-warning btn-outline btn-sm"
onClick={() => updateUser({ mfaEnabled: false })}
>
Disable MFA
</button>
)}
{Object.keys(freeSewingConfig.statuses).map((status) => (
<button
className="btn btn-warning btn-outline btn-sm"
onClick={() => updateUser({ status })}
disabled={Number(status) === user.status}
>
Set {freeSewingConfig.statuses[status].name.toUpperCase()} status
</button>
))}
</div>
{user.id ? <Json js={user} /> : null}
</div>
) : (
<Loading />
)
}

View file

@ -0,0 +1,7 @@
export const CodeBox = ({ children }) => (
<div className="hljs my-4">
<pre className="language-md hljs text-base lg:text-lg whitespace-break-spaces overflow-scroll pr-4">
{children}
</pre>
</div>
)

View file

@ -0,0 +1,231 @@
// Dependencies
import { nsMerge, capitalize, cloudflareImageUrl } from 'shared/utils.mjs'
// Hooks
import { useState, useEffect } from 'react'
import { useAccount } from 'shared/hooks/use-account.mjs'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useToast } from 'shared/hooks/use-toast.mjs'
import { useTranslation } from 'next-i18next'
// Components
import { Popout } from 'shared/components/popout/index.mjs'
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
import { DesignPicker } from './design-picker.mjs'
import {
TitleInput,
SlugInput,
ImageInput,
CaptionInput,
IntroInput,
BodyInput,
} from './inputs.mjs'
import { Collapse } from 'shared/components/collapse.mjs'
import { Tab } from 'shared/components/account/bio.mjs'
import { CodeBox } from 'shared/components/code-box.mjs'
export const ns = nsMerge('account', authNs)
const Title = ({ children }) => (
<h3 className="flex flex-row flex-wrap items-end justify-between">{children}</h3>
)
const Tip = ({ children }) => <p className="italic opacity-70">{children}</p>
const Item = ({ title, children }) => (
<div className="collapse collapse-arrow bg-base-100 hover:bg-secondary hover:bg-opacity-20">
<input type="radio" name="my-accordion-2" />
<div className="collapse-title text-xl font-medium">{title}</div>
<div className="collapse-content bg-base-100 hover:bg-base-100">{children}</div>
</div>
)
export const CreateShowcasePost = ({ noTitle = false }) => {
const { account } = useAccount()
const backend = useBackend()
const toast = useToast()
const { t } = useTranslation(ns)
const [designs, setDesigns] = useState([])
const [title, setTitle] = useState('')
const [slug, setSlug] = useState(false)
const [img, setImg] = useState(false)
const [caption, setCaption] = useState('')
const [intro, setIntro] = useState('')
const [body, setBody] = useState('')
const [extraImages, setExtraImages] = useState({})
const [activeTab, setActiveTab] = useState('create')
// Shared props for tabs
const tabProps = { activeTab, setActiveTab, t }
const addImage = () => {
const id = Object.keys(extraImages).length + 1
const newImages = { ...extraImages }
newImages[id] = null
setExtraImages(newImages)
}
const setExtraImg = (key, img) => {
console.log('setting extra', { key, img })
const newImages = { ...extraImages }
newImages[key] = img
setExtraImages(newImages)
}
return (
<AuthWrapper>
<div className="max-w-2xl">
{!noTitle && <h1>{t('showcaseNew')}</h1>}
<Tip>{t('showcaseNewInfo')}</Tip>
<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) => (
<>
<ImageInput
key={key}
setImg={(img) => setExtraImg(key, img)}
type="showcase"
subId={key}
img={extraImages[key]}
slug={slug}
/>
</>
))}
<button className="btn btn-secondary mt-2" onClick={addImage}>
Add Image
</button>
{Object.keys(extraImages).length > 0 && (
<>
<h5>Using extra images in your post</h5>
<p>To include these images, use this markup:</p>
<CodeBox>
{Object.keys(extraImages)
.map((key) => `[Image caption here][img${key}]`)
.join('\n\n')}
</CodeBox>
<p>Then, at at the bottom of your post, make sure to include this:</p>
<CodeBox>
{Object.keys(extraImages)
.map(
(key) =>
`[img${key}]: ${cloudflareImageUrl({
id: extraImages[key],
variant: 'main',
})}`
)
.join('\n')}
</CodeBox>
<pre></pre>
</>
)}
</>
) : (
<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>
</>
) : (
<p>Post preview here</p>
)}
</div>
</AuthWrapper>
)
}

View file

@ -0,0 +1,46 @@
import { collection } from 'shared/hooks/use-design.mjs'
export const DesignPicker = ({ designs = [], setDesigns }) => {
return (
<>
<div className="flex flex-row items-center gap-1 flex-wrap mb-4">
{designs.length > 0 && (
<>
<b>Featured:</b>
{designs.map((d) => (
<button
key={d}
className="btn btn-sm btn-success hover:btn-error capitalize"
onClick={() => setDesigns(designs.filter((des) => d !== des))}
>
{d}
</button>
))}
<button
className="btn btn-sm btn-warning hover:btn-error capitalize"
onClick={() => setDesigns([])}
>
Clear All
</button>
</>
)}
</div>
<div className="flex flex-row items-center gap-1 flex-wrap">
<b>Not featured:</b>
{collection
.filter((d) => designs.includes(d) === false)
.map((d, i) => (
<button
key={d}
className="btn btn-sm btn-neutral btn-outline hover:btn-success capitalize"
onClick={() => setDesigns([...designs, d])}
>
{d}
</button>
))}
</div>
</>
)
return <p>Design picker here</p>
}

View file

@ -0,0 +1,147 @@
// Dependencies
import { slugify, slugifyNoTrim, cloudflareImageUrl } from 'shared/utils.mjs'
// Hooks
import { useTranslation } from 'react-i18next'
import { useEffect, useState, useCallback } from 'react'
import { useBackend } from 'shared/hooks/use-backend.mjs'
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'
export const ns = ['account']
export const CaptionInput = ({ caption, setCaption }) => (
<input
className="input input-text input-bordered input-lg w-full"
value={caption}
placeholder="Type your caption here"
onChange={(evt) => setCaption(evt.target.value)}
/>
)
export const IntroInput = ({ intro, setIntro }) => (
<input
className="input input-text input-bordered input-lg w-full"
value={intro}
placeholder="Type your intro here"
onChange={(evt) => setIntro(evt.target.value)}
/>
)
export const BodyInput = ({ body, setBody }) => (
<textarea
className="input input-text input-bordered input-lg w-full h-96"
placeholder="Type your post body here"
onChange={(evt) => setBody(evt.target.value)}
rows={16}
>
{body}
</textarea>
)
export const TitleInput = ({ title, setTitle }) => (
<input
className="input input-text input-bordered input-lg w-full"
value={title}
placeholder="Type your title here"
onChange={(evt) => setTitle(evt.target.value)}
/>
)
export const SlugInput = ({ slug, setSlug, title }) => {
useEffect(() => {
if (title !== slug) setSlug(slugify(title))
}, [title])
return (
<input
className="input input-text input-bordered input-lg w-full mb-2"
value={slug}
placeholder="Type your title here"
onChange={(evt) => setSlug(slugifyNoTrim(evt.target.value))}
/>
)
}
export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId = false }) => {
const backend = useBackend()
const toast = useToast()
const { t } = useTranslation(ns)
const [uploading, setUploading] = useState(false)
const onDrop = useCallback(
(acceptedFiles) => {
const reader = new FileReader()
reader.onload = async () => {
setUploading(true)
const result = await backend.uploadImage({ type, subId, slug, img: reader.result })
setUploading(false)
if (result.success) setImg(result.data.imgId)
}
acceptedFiles.forEach((file) => reader.readAsDataURL(file))
},
[slug]
)
const { getRootProps, getInputProps } = useDropzone({ onDrop })
const removeImage = async () => {
setUploading(true)
const result = await backend.removeImage(img)
setUploading(false)
if (result.response.status === 204) setImg('')
}
if (!slug)
return (
<Popout note compact>
A <b>slug</b> is mandatory
</Popout>
)
if (!subId)
return (
<Popout note compact>
A <b>subId</b> prop is mandatory
</Popout>
)
if (uploading) return <Loading />
if (img)
return (
<div>
<div
className="bg-base-100 w-full h-36 mb-2"
style={{
backgroundImage: `url(${cloudflareImageUrl({
id: img,
variant: 'sq500',
})})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
}}
></div>
<button className="btn btn-error btn-sm" onClick={removeImage}>
Remove Image
</button>
</div>
)
return (
<div>
<div
{...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
`}
>
<input {...getInputProps()} />
<p className="hidden lg:block p-0 m-0">{t('imgDragAndDropImageHere')}</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>
</div>
</div>
)
}

View file

@ -1,6 +1,7 @@
import Link from 'next/link'
import { useTranslation } from 'next-i18next'
import { useAccount } from 'shared/hooks/use-account.mjs'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { roles } from 'config/roles.mjs'
import { useEffect, useState } from 'react'
import { Loading } from 'shared/components/spinner.mjs'
@ -8,19 +9,20 @@ import { Loading } from 'shared/components/spinner.mjs'
export const ns = ['auth']
const Wrap = ({ children }) => (
<div className="m-auto max-w-lg text-center mt-24 p-8">{children}</div>
<div className="m-auto max-w-xl text-center mt-24 p-8">{children}</div>
)
const ContactSupport = ({ t }) => (
<div className="flex flex-row items-center justify-center gap-4 mt-8">
<Link href="/support" className="btn btn-warning w-full">
<Link href="/support" className="btn btn-success w-full">
{t('contactSupport')}
</Link>
</div>
)
const AuthRequired = ({ t }) => (
const AuthRequired = ({ t, banner }) => (
<Wrap>
{banner}
<h1>{t('authRequired')}</h1>
<p>{t('membersOnly')}</p>
<div className="flex flex-row items-center justify-center gap-4 mt-8">
@ -34,8 +36,9 @@ const AuthRequired = ({ t }) => (
</Wrap>
)
const AccountInactive = ({ t }) => (
const AccountInactive = ({ t, banner }) => (
<Wrap>
{banner}
<h1>{t('accountInactive')}</h1>
<p>{t('accountInactiveMsg')}</p>
<p>{t('signupAgain')}</p>
@ -47,40 +50,45 @@ const AccountInactive = ({ t }) => (
</Wrap>
)
const AccountDisabled = ({ t }) => (
const AccountDisabled = ({ t, banner }) => (
<Wrap>
{banner}
<h1>{t('accountDisabled')}</h1>
<p>{t('accountDisabledMsg')}</p>
<ContactSupport t={t} />
</Wrap>
)
const AccountProhibited = ({ t }) => (
const AccountProhibited = ({ t, banner }) => (
<Wrap>
{banner}
<h1>{t('accountProhibited')}</h1>
<p>{t('accountProhibitedMsg')}</p>
<ContactSupport t={t} />
</Wrap>
)
const AccountStatusUnknown = ({ t }) => (
const AccountStatusUnknown = ({ t, banner }) => (
<Wrap>
{banner}
<h1>{t('statusUnknown')}</h1>
<p>{t('statusUnknownMsg')}</p>
<ContactSupport t={t} />
</Wrap>
)
const RoleLacking = ({ t, requiredRole, role }) => (
const RoleLacking = ({ t, requiredRole, role, banner }) => (
<Wrap>
{banner}
<h1>{t('roleLacking')}</h1>
<p dangerouslySetInnerHTML={{ __html: t('roleLackingMsg', { requiredRole, role }) }} />
<ContactSupport t={t} />
</Wrap>
)
const ConsentLacking = ({ t }) => (
const ConsentLacking = ({ t, banner }) => (
<Wrap>
{banner}
<h1>{t('consentLacking')}</h1>
<p>{t('membersOnly')}</p>
<div className="flex flex-row items-center justify-center gap-4 mt-8">
@ -96,28 +104,55 @@ const ConsentLacking = ({ t }) => (
export const AuthWrapper = ({ children, app, requiredRole = 'user' }) => {
const { t } = useTranslation(ns)
const { account, token } = useAccount()
const { account, token, admin, stopImpersonating } = useAccount()
const backend = useBackend()
const [ready, setReady] = useState(false)
const [impersonating, setImpersonating] = useState(false)
/*
* Avoid hydration errors
*/
useEffect(() => setReady(true))
useEffect(() => {
const verifyAdmin = async () => {
const result = await backend.adminPing(admin.token)
if (result.success && result.data.account.role === 'admin') {
setImpersonating({
admin: result.data.account.username,
user: account.username,
})
}
}
if (admin && admin.token) verifyAdmin()
setReady(true)
}, [admin])
if (!ready) return <Loading />
if (!token || !account.username) return <AuthRequired t={t} />
const banner = impersonating ? (
<div className="bg-warning rounded-lg shadow py-4 px-6 flex flex-row items-center gap-4 justify-between">
<span className="text-base-100 text-left">
Hi <b>{impersonating.admin}</b>, you are currently impersonating <b>{impersonating.user}</b>
</span>
<button className="btn btn-neutral" onClick={stopImpersonating}>
Stop Impersonating
</button>
</div>
) : null
const childProps = { t, banner }
if (!token || !account.username) return <AuthRequired {...childProps} />
if (account.status !== 1) {
if (account.status === 0) return <AccountInactive t={t} />
if (account.status === -1) return <AccountDisabled t={t} />
if (account.status === -2) return <AccountProhibited t={t} />
return <AccountStatusUnknown t={t} />
if (account.status === 0) return <AccountInactive {...childProps} />
if (account.status === -1) return <AccountDisabled {...childProps} />
if (account.status === -2) return <AccountProhibited {...childProps} />
return <AccountStatusUnknown {...childProps} />
}
if (account.consent < 1) return <ConsentLacking />
if (account.consent < 1) return <ConsentLacking {...childProps} />
if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) {
return <RoleLacking t={t} role={account.role} requiredRole={requiredRole} />
return <RoleLacking {...childProps} role={account.role} requiredRole={requiredRole} />
}
return children

View file

@ -4,6 +4,7 @@ import createPersistedState from 'use-persisted-state'
* Set up local storage state for account & token
*/
const usePersistedAccount = createPersistedState('fs-account')
const usePersistedAdmin = createPersistedState('fs-admin')
const usePersistedToken = createPersistedState('fs-token')
const usePersistedSeenUser = createPersistedState('fs-seen-user')
@ -18,6 +19,7 @@ const noAccount = { username: false, control: 2 }
export function useAccount() {
// (persisted) State (saved to local storage)
const [account, setAccount] = usePersistedAccount(noAccount)
const [admin, setAdmin] = usePersistedAdmin(noAccount)
const [token, setToken] = usePersistedToken(null)
const [seenUser, setSeenUser] = usePersistedSeenUser(false)
@ -27,6 +29,26 @@ export function useAccount() {
setToken(null)
}
// Impersonate a user.
// Only admins can do this but that is enforced at the backend.
const impersonate = (data) => {
setAdmin({ token, account })
const newAccount = {
...data.account,
impersonatingAdmin: { id: account.id, username: account.username },
}
setAdmin({ token, account: { ...account } })
setAccount(newAccount)
}
const stopImpersonating = () => {
setAccount(admin.account)
setToken(admin.token)
clearAdmin()
}
const clearAdmin = () => setAdmin(noAccount)
return {
account,
setAccount,
@ -35,5 +57,9 @@ export function useAccount() {
seenUser,
setSeenUser,
signOut,
admin,
clearAdmin,
impersonate,
stopImpersonating,
}
}

View file

@ -8,9 +8,11 @@ import { useMemo } from 'react'
*/
const apiHandler = axios.create({
baseURL: freeSewingConfig.backend,
timeout: 3000,
timeout: 6660,
})
const auth = (token) => (token ? { headers: { Authorization: 'Bearer ' + token } } : {})
/*
* This api object handles async code for different HTTP methods
*/
@ -316,6 +318,20 @@ Backend.prototype.confirmNewsletterUnsubscribe = async function ({ id, ehash })
return responseHandler(await api.delete('/subscriber', { id, ehash }))
}
/*
* Upload an image
*/
Backend.prototype.uploadImage = async function (body) {
return responseHandler(await api.post('/images/jwt', body, this.auth))
}
/*
* Remove an (uploaded) image
*/
Backend.prototype.removeImage = async function (id) {
return responseHandler(await api.delete(`/images/${id}/jwt`, this.auth))
}
/*
* Search user (admin method)
*/
@ -337,20 +353,27 @@ Backend.prototype.adminUpdateUser = async function ({ id, data }) {
return responseHandler(await api.patch(`/admin/user/${id}/jwt`, data, this.auth))
}
/*
* Impersonate user (admin method)
*/
Backend.prototype.adminImpersonateUser = async function (id) {
return responseHandler(await api.get(`/admin/impersonate/${id}/jwt`, this.auth))
}
/*
* Verify an admin account while impersonating another user
*/
Backend.prototype.adminPing = async function (token) {
return responseHandler(await api.get(`/whoami/jwt`, auth(token)))
}
export function useBackend() {
const { token } = useAccount()
/*
* This backend object is what we'll end up returning
*/
const backend = useMemo(() => {
/*
* Set up authentication headers
*/
const auth = token ? { headers: { Authorization: 'Bearer ' + token } } : {}
return new Backend(auth)
}, [token])
const backend = useMemo(() => new Backend(auth(token)), [token])
return backend
}

View file

@ -54,6 +54,16 @@ export const extendSiteNav = async (siteNav, lang) => {
s: 'new/set',
0: 20,
},
showcase: {
t: t('newShowcase'),
s: 'new/showcase',
0: 30,
},
blog: {
t: t('newBlog'),
s: 'new/blog',
0: 30,
},
}
// Add designs

View file

@ -1,10 +1,25 @@
import tlds from 'tlds/index.json' assert { type: 'json' }
import _slugify from 'slugify'
import get from 'lodash.get'
import set from 'lodash.set'
import orderBy from 'lodash.orderby'
import unset from 'lodash.unset'
import { cloudflareConfig } from './config/cloudflare.mjs'
const slugifyConfig = {
replacement: '-', // replace spaces with replacement character, defaults to `-`
remove: undefined, // remove characters that match regex, defaults to `undefined`
lower: true, // convert to lower case, defaults to `false`
strict: true, // strip special characters except replacement, defaults to `false`
locale: 'en', // language code of the locale to use
trim: true, // trim leading and trailing replacement chars, defaults to `true`
}
// Slugify a string
export const slugify = (input) => _slugify(input, slugifyConfig)
// Slugify a string, but don't trim it. Handy when slugifying user input
export const slugifyNoTrim = (input) => _slugify(input, { ...slugifyConfig, trim: false })
// Method that returns a unique ID when all you need is an ID
// but you can't be certain you have one
export const getId = (id) => (id ? id : Date.now())

3177
yarn.lock

File diff suppressed because it is too large Load diff