allow to sort navigation items

This commit is contained in:
EGOIST 2022-05-13 15:21:49 +08:00
parent 7c3b9d5f3b
commit fb78c9660b
10 changed files with 182 additions and 86 deletions

View File

@ -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",

View File

@ -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'}

View File

@ -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 }

View File

@ -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}`
}

View File

@ -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]

View File

@ -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])

View File

@ -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>

View File

@ -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")

View File

@ -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")

View File

@ -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