feat: shorts

This commit is contained in:
DIYgod 2023-08-25 19:02:57 +01:00
parent 9776bbf7e1
commit 215bd8fbc0
No known key found for this signature in database
12 changed files with 522 additions and 37 deletions

View File

@ -1,5 +1,6 @@
import PortfolioEditor from "./portfolio-editor"
import PostEditor from "./post-editor"
import ShortEditor from "./short-editor"
export default function SubdomainEditor({
searchParams,
@ -10,6 +11,8 @@ export default function SubdomainEditor({
}) {
if (searchParams?.type === "portfolio") {
return <PortfolioEditor />
} else if (searchParams?.type === "short") {
return <ShortEditor />
} else {
return <PostEditor />
}

View File

@ -0,0 +1,332 @@
"use client"
import { nanoid } from "nanoid"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import { memo, useCallback, useEffect, useState } from "react"
import toast from "react-hot-toast"
import { useQueryClient } from "@tanstack/react-query"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { PublishButton } from "~/components/dashboard/PublishButton"
import PublishedModal from "~/components/dashboard/PublishedModal"
import EditorContent from "~/components/dashboard/editor-properties/EditorContent"
import EditorImages from "~/components/dashboard/editor-properties/EditorImages"
import EditorPublishAt from "~/components/dashboard/editor-properties/EditorPublishAt"
import EditorTitle from "~/components/dashboard/editor-properties/EditorTitle"
import { useModalStack } from "~/components/ui/ModalStack"
import {
Values,
initialEditorState,
useEditorState,
} from "~/hooks/useEditorState"
import { useGetState } from "~/hooks/useGetState"
import { useBeforeMounted } from "~/hooks/useSyncOnce"
import { showConfetti } from "~/lib/confetti"
import { CSB_SCAN } from "~/lib/env"
import { getTwitterShareUrl } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import { getPageVisibility } from "~/lib/page-helpers"
import { delStorage, setStorage } from "~/lib/storage"
import { PageVisibilityEnum } from "~/lib/types"
import { cn } from "~/lib/utils"
import {
useCreatePage,
useDeletePage,
useGetPage,
useUpdatePage,
} from "~/queries/page"
import { useGetSite } from "~/queries/site"
export default function ShortEditor() {
const router = useRouter()
const queryClient = useQueryClient()
const { t } = useTranslation("dashboard")
const params = useParams()
const subdomain = params?.subdomain as string
const searchParams = useSearchParams()
let pageId = searchParams?.get("id") as string | undefined
const type = "short"
const site = useGetSite(subdomain)
const [draftKey, setDraftKey] = useState<string>("")
useEffect(() => {
if (subdomain) {
let key
if (!pageId) {
const randomId = nanoid()
key = `draft-${site.data?.characterId}-!local-${randomId}`
queryClient.invalidateQueries([
"getPagesBySite",
site.data?.characterId,
])
router.replace(
`/dashboard/${subdomain}/editor?id=!local-${randomId}&type=${type}`,
)
} else {
key = `draft-${site.data?.characterId}-${pageId}`
}
setDraftKey(key)
}
}, [
subdomain,
pageId,
queryClient,
router,
site.data?.characterId,
searchParams,
])
const page = useGetPage({
characterId: site.data?.characterId,
noteId: pageId && /\d+/.test(pageId) ? +pageId : undefined,
slug: pageId || draftKey.replace(`draft-${site.data?.characterId}-`, ""),
handle: subdomain,
})
const [visibility, setVisibility] = useState<PageVisibilityEnum>()
useEffect(() => {
if (page.isSuccess) {
setVisibility(getPageVisibility(page.data || undefined))
}
}, [page.isSuccess, page.data])
// reset editor state when page changes
useBeforeMounted(() => {
useEditorState.setState({
...initialEditorState,
})
})
const values = useEditorState()
const getValues = useGetState(values)
const updateValue = useCallback(
(val: Partial<Values>) => {
if (visibility !== PageVisibilityEnum.Draft) {
setVisibility(PageVisibilityEnum.Modified)
}
const values = getValues()
const newValues = { ...values, ...val }
if (draftKey) {
setStorage(draftKey, {
date: +new Date(),
values: newValues,
type,
})
queryClient.invalidateQueries([
"getPagesBySite",
site.data?.characterId,
])
}
useEditorState.setState(newValues)
},
[visibility],
)
// Save
const createPage = useCreatePage()
const updatePage = useUpdatePage()
const savePage = async () => {
const baseValues = {
...values,
characterId: site.data?.characterId,
}
if (visibility === PageVisibilityEnum.Draft) {
createPage.mutate({
...baseValues,
type,
})
} else {
updatePage.mutate({
...baseValues,
noteId: page?.data?.noteId,
})
}
}
const { present } = useModalStack()
useEffect(() => {
if (createPage.isSuccess || updatePage.isSuccess) {
if (draftKey) {
delStorage(draftKey)
queryClient.invalidateQueries([
"getPagesBySite",
site.data?.characterId,
])
queryClient.invalidateQueries([
"getPage",
draftKey.replace(`draft-${site.data?.characterId}-`, ""),
])
} else {
queryClient.invalidateQueries(["getPage", pageId])
}
if (createPage.data?.noteId) {
router.replace(
`/dashboard/${subdomain}/editor?id=${createPage.data?.noteId}&type=${type}`,
)
}
const transactionUrl = `${CSB_SCAN}/tx/${
page.data?.updatedTransactionHash || page.data?.transactionHash // TODO
}`
const modalId = "publish-modal"
present({
title: `🎉 ${t("Published!")}`,
id: modalId,
content: (props) => (
<PublishedModal transactionUrl={transactionUrl} {...props} />
),
})
showConfetti()
createPage.reset()
updatePage.reset()
}
}, [createPage.isSuccess, updatePage.isSuccess])
useEffect(() => {
if (createPage.isError || updatePage.isError) {
toast.error("Error: " + (createPage.error || updatePage.error))
createPage.reset()
updatePage.reset()
}
}, [createPage.isError, updatePage.isSuccess])
// Delete
const deleteP = useDeletePage()
const deletePage = async () => {
if (page.data) {
if (!page.data?.noteId) {
// Is draft
delStorage(`draft-${page.data.characterId}-${page.data.draftKey}`)
} else {
// Is Note
return deleteP.mutate({
noteId: page.data.noteId,
characterId: page.data.characterId,
})
}
}
}
useEffect(() => {
if (deleteP.isSuccess) {
toast.success(t("Deleted!"))
deleteP.reset()
router.push(`/dashboard/${subdomain}/${type}s`)
}
}, [deleteP.isSuccess])
// Init
useEffect(() => {
if (!page.data?.metadata || !draftKey) return
useEditorState.setState({
published: !!page.data.noteId,
title: page.data.metadata?.content?.title || "",
publishedAt: page.data.metadata?.content?.date_published,
content: page.data.metadata?.content?.content || "",
images:
page.data.metadata?.content?.attachments?.filter(
(attachment) => attachment.name === "image",
) || [],
})
}, [page.data, subdomain, draftKey, site.data?.characterId])
// Reset
const discardChanges = useCallback(() => {
if (draftKey) {
delStorage(draftKey)
queryClient.invalidateQueries(["getPagesBySite", site.data?.characterId])
page.remove()
page.refetch()
}
}, [draftKey, site.data?.characterId])
return (
<>
<DashboardMain className="max-w-screen-lg" title="Edit short">
{page.isLoading ? (
<div className="flex justify-center items-center min-h-[300px]">
{t("Loading")}...
</div>
) : (
<>
<EditorExtraProperties updateValue={updateValue} />
<div className="flex justify-between h-14 items-center text-sm mt-8">
<div className="flex items-center space-x-3 flex-shrink-0">
<PublishButton
savePage={savePage}
deletePage={deletePage}
twitterShareUrl={
page.data && site.data
? getTwitterShareUrl({
page: page.data,
site: site.data,
t,
})
: ""
}
published={visibility !== PageVisibilityEnum.Draft}
isSaving={
createPage.isLoading ||
updatePage.isLoading ||
deleteP.isLoading
}
isDisabled={false}
type={type}
isModified={visibility === PageVisibilityEnum.Modified}
discardChanges={discardChanges}
placement="top-start"
/>
<span
className={cn(
`text-sm capitalize`,
visibility === PageVisibilityEnum.Draft
? `text-zinc-300`
: visibility === PageVisibilityEnum.Modified
? "text-orange-600"
: "text-green-600",
)}
>
{t(visibility as string)}
</span>
</div>
</div>
</>
)}
</DashboardMain>
</>
)
}
const EditorExtraProperties = memo(
({ updateValue }: { updateValue: (val: Partial<Values>) => void }) => {
const { t } = useTranslation("dashboard")
return (
<div className="w-full space-y-5">
<EditorImages updateValue={updateValue} />
<EditorTitle updateValue={updateValue} />
<EditorContent updateValue={updateValue} />
<EditorPublishAt
updateValue={updateValue}
prompt={t(
"This short will be accessible from this time. Leave blank to use the current time.",
)}
/>
</div>
)
},
)
EditorExtraProperties.displayName = "EditorExtraProperties"

View File

@ -120,6 +120,13 @@ export default function DashboardLayout({
text: "Pages",
lever: 2,
},
{
href: `/dashboard/${subdomain}/shorts`,
isActive: ({ href, pathname }) => href === pathname,
icon: "icon-[mingcute--ins-line]",
text: "Shorts",
lever: 2,
},
{
href: `/dashboard/${subdomain}/portfolios`,
isActive: ({ href, pathname }) => href === pathname,

View File

@ -0,0 +1,5 @@
import { PagesManager } from "~/components/dashboard/PagesManager"
export default function SubdomainPages() {
return <PagesManager type="short" />
}

View File

@ -0,0 +1,36 @@
import { ChangeEvent } from "react"
import { Input } from "~/components/ui/Input"
import { Values, useEditorState } from "~/hooks/useEditorState"
import { useTranslation } from "~/lib/i18n/client"
export default function EditorContent({
updateValue,
prompt,
}: {
updateValue: (val: Partial<Values>) => void
prompt?: string
}) {
const { t } = useTranslation("dashboard")
const value = useEditorState((state) => state.content)
return (
<div>
<Input
label={t("Content") || ""}
isBlock
name="content"
id="content"
value={value}
multiline
rows={4}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
updateValue({
content: e.target.value,
})
}}
help={prompt}
/>
</div>
)
}

View File

@ -0,0 +1,71 @@
import { useState } from "react"
import { FieldLabel } from "~/components/ui/FieldLabel"
import { ImageUploader } from "~/components/ui/ImageUploader"
import { Values, useEditorState } from "~/hooks/useEditorState"
import { useTranslation } from "~/lib/i18n/client"
export default function EditorImages({
updateValue,
prompt,
}: {
updateValue: (val: Partial<Values>) => void
prompt?: string
}) {
const { t } = useTranslation("dashboard")
const value = useEditorState((state) => state.images)
const [extraValue, setExtraValue] = useState(undefined)
return (
<div>
<FieldLabel label={t("Images")} />
<div className="grid grid-cols-4 gap-2">
{value.map((image, index) => (
<ImageUploader
key={image.address}
className="aspect-video rounded-lg"
value={image}
hasClose={true}
withMimeType={true}
uploadEnd={(key) => {
const tmpValue = [...value]
if (key) {
tmpValue[index] = key
} else {
tmpValue.splice(index, 1)
}
updateValue({
images: tmpValue,
})
}}
accept="image/*"
/>
))}
<ImageUploader
key={"image"}
className="aspect-video rounded-lg"
withMimeType={true}
value={extraValue}
disablePreview={true}
uploadEnd={(key) => {
if (key) {
const tmpValue = [
...value,
{
address: key.address,
mime_type: key.mime_type,
},
]
updateValue({
images: tmpValue,
})
}
setExtraValue(undefined)
}}
accept="image/*"
/>
</div>
{prompt && <div className="text-xs text-gray-400 mt-1">{prompt}</div>}
</div>
)
}

View File

@ -23,6 +23,7 @@ export const ImageUploader = forwardRef(function ImageUploader(
withMimeType,
hasClose,
accept,
disablePreview,
...inputProps
}: {
className?: string
@ -30,6 +31,7 @@ export const ImageUploader = forwardRef(function ImageUploader(
uploadStart?: () => void
hasClose?: boolean
accept?: string
disablePreview?: boolean
} & (
| {
withMimeType?: false
@ -55,9 +57,11 @@ export const ImageUploader = forwardRef(function ImageUploader(
const handleChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files?.[0]) {
getBase64(event.target.files[0], (url) => {
setImageUrl(url)
})
if (!disablePreview) {
getBase64(event.target.files[0], (url) => {
setImageUrl(url)
})
}
setLoading(true)
uploadStart?.()

View File

@ -14,7 +14,9 @@ export const initialEditorState = {
},
disableAISummary: false,
externalUrl: "",
images: [],
}
export type Values = {
title: string
publishedAt: string
@ -29,6 +31,10 @@ export type Values = {
}
disableAISummary: boolean
externalUrl: string
images: {
address?: string
mime_type?: string
}[]
}
export const useEditorState = create<

View File

@ -63,14 +63,25 @@ export const expandCrossbellNote = async ({
)?.address || rendered?.cover
expandedNote.metadata.content.images = []
const cover = expandedNote.metadata?.content?.attachments?.find(
(attachment) => attachment.name === "cover",
)?.address
if (cover) {
expandedNote.metadata.content.images.push(cover)
}
const attachmentsImages = expandedNote.metadata?.content?.attachments
?.filter(
(attachment) => attachment.name === "image" && attachment.address,
)
.map((attachment) => attachment.address!)
expandedNote.metadata.content.images =
expandedNote.metadata.content.images.concat(attachmentsImages || [])
expandedNote.metadata.content.images =
expandedNote.metadata.content.images.concat(rendered?.images || [])
expandedNote.metadata.content.images = [
...new Set(expandedNote.metadata.content.images),
]

View File

@ -124,4 +124,4 @@ export type ExpandedCharacter = CharacterEntity & {
export type ColorScheme = "dark" | "light"
export type NoteType = "post" | "page" | "portfolio"
export type NoteType = "post" | "page" | "portfolio" | "short"

View File

@ -143,6 +143,13 @@ const getLocalPages = (input: {
},
]
: []),
...(page.values.images?.length
? page.values.images?.map((image: any) => ({
name: "image",
address: image?.address,
mime_type: image?.mime_type,
}))
: []),
],
},
},

View File

@ -25,6 +25,7 @@ import {
useQueryClient,
} from "@tanstack/react-query"
import { type Values } from "~/hooks/useEditorState"
import createSearchParams from "~/lib/search-params"
import { NoteType } from "~/lib/types"
import * as pageModel from "~/models/page.model"
@ -214,22 +215,12 @@ export function useCreatePage() {
const { mutateAsync: _, ...postNote } = usePostNote()
const mutate = useRefCallback(
(input: {
characterId?: number
slug?: string
tags?: string
title?: string
content?: string
publishedAt?: string
excerpt?: string
type?: NoteType
cover?: {
address?: string
mime_type?: string
}
disableAISummary?: boolean
externalUrl?: string
}) => {
(
input: {
characterId?: number
type?: NoteType
} & Values,
) => {
if (!input.characterId) {
throw new Error("characterId is required")
}
@ -274,6 +265,13 @@ export function useCreatePage() {
},
]
: []),
...(input.images?.length
? input.images?.map((image) => ({
name: "image",
address: image.address,
mime_type: image.mime_type,
}))
: []),
],
external_urls: [input.externalUrl],
} as NoteMetadata & {
@ -301,23 +299,13 @@ export function useUpdatePage() {
const { mutateAsync: _, ...updateNote } = useUpdateNote()
const mutate = useRefCallback(
(input: {
noteId?: number
characterId?: number
slug?: string
tags?: string
title?: string
content?: string
publishedAt?: string
excerpt?: string
type?: NoteType
cover?: {
address?: string
mime_type?: string
}
disableAISummary?: boolean
externalUrl?: string
}) => {
(
input: {
noteId?: number
characterId?: number
type?: NoteType
} & Values,
) => {
if (!input.characterId || !input.noteId) {
throw new Error("characterId and noteId are required")
}
@ -412,6 +400,21 @@ export function useUpdatePage() {
}
}
if (input.images?.length) {
if (!metadataDraft.attachments) {
metadataDraft.attachments = []
}
metadataDraft.attachments = metadataDraft.attachments
?.filter((attr) => attr.name !== "image")
.concat(
input.images?.map((image) => ({
name: "image",
address: image.address,
mime_type: image.mime_type,
})),
)
}
if (input.externalUrl) {
if (!metadataDraft.external_urls) {
metadataDraft.external_urls = []