wip(org): Working on admin & content creation pages
This commit is contained in:
parent
61da3e1dab
commit
50b6747584
26 changed files with 1221 additions and 3383 deletions
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
const UserAdminPage = ({ page, userId }) => (
|
||||
<PageWrapper {...page} title={`User ${userId}`}>
|
||||
<AuthWrapper requiredRole="admin">{userId ? <ManageUser userId={userId} /> : null}</AuthWrapper>
|
||||
</PageWrapper>
|
||||
)
|
||||
|
||||
export default UserAdminPage
|
||||
|
||||
|
|
|
@ -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 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 ? (
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
<Box
|
||||
title={t('pluginNew')}
|
||||
Icon={PluginIcon}
|
||||
description={t('pluginNewInfo')}
|
||||
href="https://freesewing.dev/guides/plugins"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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
|
||||
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
|
||||
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}
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
|
42
sites/org/pages/new/showcase.mjs
Normal file
42
sites/org/pages/new/showcase.mjs
Normal 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'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
200
sites/shared/components/admin.mjs
Normal file
200
sites/shared/components/admin.mjs
Normal 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 />
|
||||
)
|
||||
}
|
7
sites/shared/components/code-box.mjs
Normal file
7
sites/shared/components/code-box.mjs
Normal 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>
|
||||
)
|
231
sites/shared/components/github/create-showcase.mjs
Normal file
231
sites/shared/components/github/create-showcase.mjs
Normal 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>
|
||||
)
|
||||
}
|
46
sites/shared/components/github/design-picker.mjs
Normal file
46
sites/shared/components/github/design-picker.mjs
Normal 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>
|
||||
}
|
147
sites/shared/components/github/inputs.mjs
Normal file
147
sites/shared/components/github/inputs.mjs
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue