allow to sort navigation items
This commit is contained in:
parent
7c3b9d5f3b
commit
fb78c9660b
|
|
@ -30,6 +30,7 @@
|
|||
"cookie": "^0.5.0",
|
||||
"dayjs": "^1.11.1",
|
||||
"dotenv": "^16.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"form-data": "^4.0.0",
|
||||
"mailgun.js": "^5.2.2",
|
||||
"markdown-it": "^13.0.0",
|
||||
|
|
@ -42,6 +43,8 @@
|
|||
"react-hook-form": "^7.31.1",
|
||||
"react-hot-toast": "^2.2.0",
|
||||
"react-query": "^3.39.0",
|
||||
"react-sortablejs": "^6.1.1",
|
||||
"sortablejs": "^1.15.0",
|
||||
"ua-parser-js": "^1.0.2",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.15.1",
|
||||
|
|
@ -57,6 +60,7 @@
|
|||
"@types/react": "^18.0.9",
|
||||
"@types/react-avatar-editor": "^12.0.0",
|
||||
"@types/react-dom": "^18.0.3",
|
||||
"@types/sortablejs": "^1.13.0",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"eslint": "^8.15.0",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ specifiers:
|
|||
'@types/react': ^18.0.9
|
||||
'@types/react-avatar-editor': ^12.0.0
|
||||
'@types/react-dom': ^18.0.3
|
||||
'@types/sortablejs': ^1.13.0
|
||||
'@types/ua-parser-js': ^0.7.36
|
||||
'@types/uuid': ^8.3.4
|
||||
'@web-std/form-data': ^3.0.2
|
||||
|
|
@ -34,6 +35,7 @@ specifiers:
|
|||
dotenv: ^16.0.0
|
||||
eslint: ^8.15.0
|
||||
eslint-config-next: ^12.1.6
|
||||
fast-deep-equal: ^3.1.3
|
||||
form-data: ^4.0.0
|
||||
mailgun.js: ^5.2.2
|
||||
markdown-it: ^13.0.0
|
||||
|
|
@ -48,6 +50,8 @@ specifiers:
|
|||
react-hook-form: ^7.31.1
|
||||
react-hot-toast: ^2.2.0
|
||||
react-query: ^3.39.0
|
||||
react-sortablejs: ^6.1.1
|
||||
sortablejs: ^1.15.0
|
||||
tailwindcss: ^3.0.24
|
||||
typescript: ^4.5.5
|
||||
ua-parser-js: ^1.0.2
|
||||
|
|
@ -76,6 +80,7 @@ dependencies:
|
|||
cookie: 0.5.0
|
||||
dayjs: 1.11.2
|
||||
dotenv: 16.0.0
|
||||
fast-deep-equal: 3.1.3
|
||||
form-data: 4.0.0
|
||||
mailgun.js: 5.2.2
|
||||
markdown-it: 13.0.1
|
||||
|
|
@ -88,6 +93,8 @@ dependencies:
|
|||
react-hook-form: 7.31.1_react@18.1.0
|
||||
react-hot-toast: 2.2.0_ef5jwxihqo6n7gxfmzogljlgcm
|
||||
react-query: 3.39.0_ef5jwxihqo6n7gxfmzogljlgcm
|
||||
react-sortablejs: 6.1.1_kjz72dk5lh6hp4ogspefhm7r2u
|
||||
sortablejs: 1.15.0
|
||||
ua-parser-js: 1.0.2
|
||||
uuid: 8.3.2
|
||||
zod: 3.15.1
|
||||
|
|
@ -103,6 +110,7 @@ devDependencies:
|
|||
'@types/react': 18.0.9
|
||||
'@types/react-avatar-editor': 12.0.0
|
||||
'@types/react-dom': 18.0.3
|
||||
'@types/sortablejs': 1.13.0
|
||||
'@types/ua-parser-js': 0.7.36
|
||||
'@types/uuid': 8.3.4
|
||||
eslint: 8.15.0
|
||||
|
|
@ -1677,6 +1685,10 @@ packages:
|
|||
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
|
||||
dev: true
|
||||
|
||||
/@types/sortablejs/1.13.0:
|
||||
resolution: {integrity: sha512-C3064MH72iEfeGCYEGCt7FCxXoAXaMPG0QPnstcxvPmbl54erpISu06d++FY37Smja64iWy5L8wOyHHBghWbJQ==}
|
||||
dev: true
|
||||
|
||||
/@types/ua-parser-js/0.7.36:
|
||||
resolution: {integrity: sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==}
|
||||
dev: true
|
||||
|
|
@ -2076,6 +2088,10 @@ packages:
|
|||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
||||
/classnames/2.3.1:
|
||||
resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==}
|
||||
dev: false
|
||||
|
||||
/clone-deep/4.0.1:
|
||||
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -2633,7 +2649,6 @@ packages:
|
|||
|
||||
/fast-deep-equal/3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
dev: true
|
||||
|
||||
/fast-glob/3.2.11:
|
||||
resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==}
|
||||
|
|
@ -3662,6 +3677,22 @@ packages:
|
|||
react-dom: 18.1.0_react@18.1.0
|
||||
dev: false
|
||||
|
||||
/react-sortablejs/6.1.1_kjz72dk5lh6hp4ogspefhm7r2u:
|
||||
resolution: {integrity: sha512-VXTCHiitk1QT1uxt+hFGSihG1phk/HAw/17a65diTT3NVm/5dui2O99X5L/tOFLgRT5ifxwZtnloTTDsT5EH2w==}
|
||||
peerDependencies:
|
||||
'@types/sortablejs': '1'
|
||||
react: '>=16.9.0'
|
||||
react-dom: '>=16.9.0'
|
||||
sortablejs: '1'
|
||||
dependencies:
|
||||
'@types/sortablejs': 1.13.0
|
||||
classnames: 2.3.1
|
||||
react: 18.1.0
|
||||
react-dom: 18.1.0_react@18.1.0
|
||||
sortablejs: 1.15.0
|
||||
tiny-invariant: 1.2.0
|
||||
dev: false
|
||||
|
||||
/react-ssr-prepass/1.5.0_react@18.1.0:
|
||||
resolution: {integrity: sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -3805,6 +3836,10 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/sortablejs/1.15.0:
|
||||
resolution: {integrity: sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==}
|
||||
dev: false
|
||||
|
||||
/source-map-js/1.0.2:
|
||||
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -3935,6 +3970,10 @@ packages:
|
|||
resolution: {integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=}
|
||||
dev: true
|
||||
|
||||
/tiny-invariant/1.2.0:
|
||||
resolution: {integrity: sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==}
|
||||
dev: false
|
||||
|
||||
/to-fast-properties/2.0.0:
|
||||
resolution: {integrity: sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=}
|
||||
engines: {node: '>=4'}
|
||||
|
|
|
|||
|
|
@ -6,34 +6,30 @@ const logLevel: Prisma.LogLevel[] = IS_PROD
|
|||
? ["info", "error", "warn"]
|
||||
: ["info", "warn", "error", "query"]
|
||||
|
||||
export const prisma = /* @__PURE__ */ singleton(
|
||||
"prisma",
|
||||
() => {
|
||||
if (!IS_PROD && !IS_BROWSER) {
|
||||
for (const k in require.cache) {
|
||||
if (k.includes("prisma")) {
|
||||
delete require.cache[k]
|
||||
}
|
||||
export const prisma = /* @__PURE__ */ singleton("prisma", () => {
|
||||
if (!IS_PROD && !IS_BROWSER) {
|
||||
for (const k in require.cache) {
|
||||
if (k.includes("prisma")) {
|
||||
delete require.cache[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client = new PrismaClient({
|
||||
log: logLevel,
|
||||
})
|
||||
const client = new PrismaClient({
|
||||
log: logLevel,
|
||||
})
|
||||
|
||||
client.$use(async (params, next) => {
|
||||
const now = Date.now()
|
||||
const result = await next(params)
|
||||
const end = Date.now()
|
||||
const duration = Math.floor(end - now)
|
||||
console.log("query took", duration, "ms")
|
||||
// See results here
|
||||
return result
|
||||
})
|
||||
client.$use(async (params, next) => {
|
||||
const now = Date.now()
|
||||
const result = await next(params)
|
||||
const end = Date.now()
|
||||
const duration = Math.floor(end - now)
|
||||
console.log("query took", duration, "ms")
|
||||
// See results here
|
||||
return result
|
||||
})
|
||||
|
||||
return client
|
||||
},
|
||||
IS_PROD
|
||||
)
|
||||
return client
|
||||
})
|
||||
|
||||
export type { Prisma }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { IS_PROD } from "./constants"
|
||||
import { OUR_DOMAIN } from "./env"
|
||||
|
||||
export const getSiteLink = ({ subdomain }: { subdomain: string }) => {
|
||||
export const getSiteLink = ({
|
||||
subdomain,
|
||||
noProtocol,
|
||||
}: {
|
||||
subdomain: string
|
||||
noProtocol?: boolean
|
||||
}) => {
|
||||
if (noProtocol) {
|
||||
return `${subdomain}.${OUR_DOMAIN}`
|
||||
}
|
||||
return `${IS_PROD ? "https" : "http"}://${subdomain}.${OUR_DOMAIN}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
// @ts-nocheck
|
||||
globalThis._singletons = globalThis._singletons || {}
|
||||
|
||||
export const singleton = <T>(
|
||||
id: string,
|
||||
factory: () => T,
|
||||
enableSingleton = true
|
||||
): T => {
|
||||
if (!globalThis._singletons[id] || !enableSingleton) {
|
||||
export const singleton = <T>(id: string, factory: () => T): T => {
|
||||
if (!globalThis._singletons[id]) {
|
||||
globalThis._singletons[id] = factory()
|
||||
}
|
||||
return globalThis._singletons[id]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { Input } from "~/components/ui/Input"
|
|||
import { trpc } from "~/lib/trpc"
|
||||
|
||||
export default function AccountProfilePage() {
|
||||
const viewer = trpc.useQuery(["auth.viewer"])
|
||||
const viewer = trpc.useQuery(["auth.viewer"], {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const updateProfile = trpc.useMutation("user.updateProfile")
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -27,8 +30,8 @@ export default function AccountProfilePage() {
|
|||
|
||||
useEffect(() => {
|
||||
if (updateProfile.isSuccess) {
|
||||
updateProfile.reset()
|
||||
toast.success("Saved!")
|
||||
updateProfile.reset()
|
||||
}
|
||||
}, [updateProfile])
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { type EditorView } from "@codemirror/view"
|
|||
import { trpc } from "~/lib/trpc"
|
||||
import { useRouter } from "next/router"
|
||||
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
|
||||
import { UniLink } from "~/components/ui/UniLink"
|
||||
|
||||
const getInputDatetimeValue = (date: Date | string) => {
|
||||
const str = dayjs(date).format()
|
||||
|
|
@ -246,7 +247,12 @@ export default function SubdomainEditor() {
|
|||
{values.slug && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
This {isPost ? "post" : "page"} will be accessible at{" "}
|
||||
{getSiteLink({ subdomain })}/{values.slug}
|
||||
<UniLink
|
||||
href={`${getSiteLink({ subdomain })}/${values.slug}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{getSiteLink({ subdomain, noProtocol: true })}/{values.slug}
|
||||
</UniLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export default function SettingsDomainsPage() {
|
|||
const subdomain = router.query.subdomain as string
|
||||
const siteResult = trpc.useQuery(["site", { site: subdomain }], {
|
||||
enabled: !!subdomain,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const ctx = trpc.useContext()
|
||||
const updateSite = trpc.useMutation("site.updateSite")
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export default function SiteSettingsGeneralPage() {
|
|||
|
||||
const siteResult = trpc.useQuery(["site", { site: subdomain }], {
|
||||
enabled: !!subdomain,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const site = siteResult.data
|
||||
const updateSite = trpc.useMutation("site.updateSite")
|
||||
|
|
|
|||
|
|
@ -1,29 +1,86 @@
|
|||
import { Button } from "~/components/ui/Button"
|
||||
import { Input } from "~/components/ui/Input"
|
||||
import { FormEvent, useEffect, useState } from "react"
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
import { SettingsLayout } from "~/components/dashboard/SettingsLayout"
|
||||
import { useRouter } from "next/router"
|
||||
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
|
||||
import { trpc } from "~/lib/trpc"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { SiteNavigationItem } from "~/lib/types"
|
||||
import { nanoid } from "nanoid"
|
||||
import { ReactSortable } from "react-sortablejs"
|
||||
import equal from "fast-deep-equal"
|
||||
|
||||
type UpdateItem = (id: string, newItem: Partial<SiteNavigationItem>) => void
|
||||
|
||||
type RemoveItem = (id: string) => void
|
||||
|
||||
const SortableNavigationItem: React.FC<{
|
||||
item: SiteNavigationItem
|
||||
updateItem: UpdateItem
|
||||
removeItem: RemoveItem
|
||||
}> = ({ item, updateItem, removeItem }) => {
|
||||
return (
|
||||
<div className="flex space-x-5 border-b p-5 bg-zinc-50 last:border-0">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="drag-handle cursor-grab -mt-1 text-zinc-400 rounded-lg h-8 w-6 flex items-center justify-center hover:text-zinc-800 hover:bg-zinc-200"
|
||||
>
|
||||
<svg className="w-5 h-8" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 20q-.825 0-1.412-.587Q7 18.825 7 18q0-.825.588-1.413Q8.175 16 9 16t1.413.587Q11 17.175 11 18q0 .825-.587 1.413Q9.825 20 9 20Zm0-6q-.825 0-1.412-.588Q7 12.825 7 12t.588-1.413Q8.175 10 9 10t1.413.587Q11 11.175 11 12q0 .825-.587 1.412Q9.825 14 9 14Zm0-6q-.825 0-1.412-.588Q7 6.825 7 6t.588-1.412Q8.175 4 9 4t1.413.588Q11 5.175 11 6t-.587 1.412Q9.825 8 9 8Zm6 0q-.825 0-1.412-.588Q13 6.825 13 6t.588-1.412Q14.175 4 15 4t1.413.588Q17 5.175 17 6t-.587 1.412Q15.825 8 15 8Zm0 6q-.825 0-1.412-.588Q13 12.825 13 12t.588-1.413Q14.175 10 15 10t1.413.587Q17 11.175 17 12q0 .825-.587 1.412Q15.825 14 15 14Zm0 6q-.825 0-1.412-.587Q13 18.825 13 18q0-.825.588-1.413Q14.175 16 15 16t1.413.587Q17 17.175 17 18q0 .825-.587 1.413Q15.825 20 15 20Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
label="Label"
|
||||
required
|
||||
id={`${item.id}-label`}
|
||||
value={item.label}
|
||||
onChange={(e) => updateItem(item.id, { label: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="URL"
|
||||
required
|
||||
id={`${item.id}-url`}
|
||||
type="text"
|
||||
value={item.url}
|
||||
pattern="(https?://|/).+"
|
||||
title="URL must start with / or http:// or https://"
|
||||
onChange={(e) => updateItem(item.id, { url: e.target.value })}
|
||||
/>
|
||||
<div className="flex items-end relative -top-[5px]">
|
||||
<Button onClick={() => removeItem(item.id)} variantColor="red">
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SiteSettingsNavigationPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [itemsModified, setItemsModified] = useState(false)
|
||||
const subdomain = router.query.subdomain as string
|
||||
|
||||
const trpcContext = trpc.useContext()
|
||||
const siteResult = trpc.useQuery(["site", { site: subdomain }], {
|
||||
enabled: !!subdomain,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const updateSite = trpc.useMutation("site.updateSite")
|
||||
|
||||
const [items, setItems] = useState<SiteNavigationItem[]>([])
|
||||
|
||||
const updateItem = (id: string, newItem: Partial<SiteNavigationItem>) => {
|
||||
const itemsModified = useMemo(() => {
|
||||
if (!siteResult.data) return false
|
||||
return !equal(items, siteResult.data.navigation)
|
||||
}, [items, siteResult.data])
|
||||
|
||||
const updateItem: UpdateItem = (id, newItem) => {
|
||||
setItems((items) => {
|
||||
return items.map((item) => {
|
||||
if (item.id === id) {
|
||||
|
|
@ -32,7 +89,6 @@ export default function SiteSettingsNavigationPage() {
|
|||
return item
|
||||
})
|
||||
})
|
||||
setItemsModified(true)
|
||||
}
|
||||
|
||||
const newEmptyItem = () => {
|
||||
|
|
@ -47,73 +103,56 @@ export default function SiteSettingsNavigationPage() {
|
|||
})
|
||||
}
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
const removeItem: RemoveItem = (id) => {
|
||||
setItems((items) => items.filter((item) => item.id !== id))
|
||||
setItemsModified(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteResult.isPreviousData && siteResult.data?.navigation) {
|
||||
if (siteResult.data?.navigation) {
|
||||
setItems(siteResult.data.navigation)
|
||||
}
|
||||
}, [siteResult.isPreviousData, siteResult.data])
|
||||
}, [siteResult.data?.navigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSite.isSuccess) {
|
||||
updateSite.reset()
|
||||
toast.success("Saved")
|
||||
setItemsModified(true)
|
||||
trpcContext.invalidateQueries(["site", { site: subdomain }])
|
||||
}
|
||||
}, [updateSite])
|
||||
}, [updateSite, trpcContext, subdomain])
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<SettingsLayout title="Site Settings" type="site">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-zinc-50 rounded-lg p-5">
|
||||
<div className="bg-zinc-50 rounded-lg overflow-hidden">
|
||||
{items.length === 0 && (
|
||||
<div className="text-center text-zinc-500">
|
||||
<div className="text-center text-zinc-500 p-5">
|
||||
No navigation items yet
|
||||
</div>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex space-x-5 border-b last:border-0 mb-5 pb-5 last:pb-0 last:mb-0"
|
||||
>
|
||||
<Input
|
||||
label="Label"
|
||||
required
|
||||
id={`${item.id}-label`}
|
||||
value={item.label}
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, { label: e.target.value })
|
||||
}
|
||||
{/** @ts-expect-error */}
|
||||
<ReactSortable
|
||||
list={items}
|
||||
setList={setItems}
|
||||
handle=".drag-handle"
|
||||
>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<SortableNavigationItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
updateItem={updateItem}
|
||||
removeItem={removeItem}
|
||||
/>
|
||||
<Input
|
||||
label="URL"
|
||||
required
|
||||
id={`${item.id}-url`}
|
||||
type="text"
|
||||
value={item.url}
|
||||
pattern="(https?://|/).+"
|
||||
title="URL must start with / or http:// or https://"
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, { url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<div className="flex items-end relative -top-[5px]">
|
||||
<Button
|
||||
onClick={() => removeItem(item.id)}
|
||||
variantColor="red"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
<style jsx global>{`
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
`}</style>
|
||||
</ReactSortable>
|
||||
</div>
|
||||
<div className="border-t pt-5 mt-10 space-x-3 flex items-center">
|
||||
<Button
|
||||
|
|
|
|||
Loading…
Reference in New Issue