diff --git a/src/app/dashboard/[subdomain]/achievements/page.tsx b/src/app/dashboard/[subdomain]/achievements/page.tsx new file mode 100644 index 00000000..99695f4d --- /dev/null +++ b/src/app/dashboard/[subdomain]/achievements/page.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useParams } from "next/navigation" + +import { AchievementItem } from "~/components/common/AchievementItem" +import { DashboardMain } from "~/components/dashboard/DashboardMain" +import { useGetAchievements, useGetSite } from "~/queries/site" + +export default function AchievementsPage() { + const params = useParams() + const subdomain = params?.subdomain as string + const site = useGetSite(subdomain) + + const achievement = useGetAchievements(site.data?.characterId) + + return ( + +
+ <> + {achievement.data?.list?.map((series) => { + let length = series.groups?.length + if (!length) { + return null + } + return ( +
+
+ {series.info.title} +
+
+ {series.groups?.map((group) => ( + + ))} +
+
+ ) + })} + +
+
+ ) +} diff --git a/src/app/dashboard/[subdomain]/comments/page.tsx b/src/app/dashboard/[subdomain]/comments/page.tsx new file mode 100644 index 00000000..b8520ec5 --- /dev/null +++ b/src/app/dashboard/[subdomain]/comments/page.tsx @@ -0,0 +1,135 @@ +"use client" + +import { useParams } from "next/navigation" +import { Virtuoso } from "react-virtuoso" + +import { CommentItem } from "~/components/common/CommentItem" +import { DashboardMain } from "~/components/dashboard/DashboardMain" +import { UniLink } from "~/components/ui/UniLink" +import { getSiteLink } from "~/lib/helpers" +import { Trans, useTranslation } from "~/lib/i18n/client" +import { useGetCommentsBySite, useGetSite } from "~/queries/site" + +export default function CommentsPage() { + const params = useParams() + const subdomain = params?.subdomain as string + const { t } = useTranslation("dashboard") + + const site = useGetSite(subdomain) + + const comments = useGetCommentsBySite({ + characterId: site.data?.characterId, + }) + + const feedUrl = + getSiteLink({ + subdomain: subdomain, + }) + "/feed/comments" + + return ( + +
+
+

+ {t( + "You can subscribe to comments through an RSS reader to receive timely reminders.", + )} +

+

+ {t("Subscription address:")}{" "} + + {feedUrl} + +

+
+
+
+ + comments.hasNextPage && comments.fetchNextPage() + } + components={{ + Footer: comments.isLoading ? Loader : undefined, + }} + itemContent={(_, p) => + p?.list?.map((comment, idx) => { + const type = comment.toNote?.metadata?.content?.tags?.[0] + let toTitle + if (type === "post" || type === "page") { + toTitle = comment.toNote?.metadata?.content?.title + } else { + if ( + (comment.toNote?.metadata?.content?.content?.length || + 0) > 30 + ) { + toTitle = + comment.toNote?.metadata?.content?.content?.slice( + 0, + 30, + ) + "..." + } else { + toTitle = comment.toNote?.metadata?.content?.content + } + } + const name = + comment?.character?.metadata?.content?.name || + `@${comment?.character?.handle}` + + return ( +
+
+ {name}{" "} + + . + + ), + }} + ns="dashboard" + /> + : +
+ +
+ ) + }) + } + >
+
+
+
+
+ ) +} + +const Loader = () => { + const { t } = useTranslation("common") + return ( +
+ {t("Loading")}... +
+ ) +} diff --git a/src/app/dashboard/[subdomain]/editor/page.tsx b/src/app/dashboard/[subdomain]/editor/page.tsx new file mode 100644 index 00000000..9fd4589f --- /dev/null +++ b/src/app/dashboard/[subdomain]/editor/page.tsx @@ -0,0 +1,874 @@ +"use client" + +import { useDebounceEffect } from "ahooks" +import type { Root } from "mdast" +import { nanoid } from "nanoid" +import { useParams, useRouter, useSearchParams } from "next/navigation" +import NodeID3 from "node-id3" +import { + ChangeEvent, + FC, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import toast from "react-hot-toast" +import { shallow } from "zustand/shallow" + +import type { EditorView } from "@codemirror/view" +import { useQueryClient } from "@tanstack/react-query" + +import { PageContent } from "~/components/common/PageContent" +import { DashboardMain } from "~/components/dashboard/DashboardMain" +import { OptionsButton } from "~/components/dashboard/OptionsButton" +import { PublishButton } from "~/components/dashboard/PublishButton" +import { Button } from "~/components/ui/Button" +import { CodeMirrorEditor } from "~/components/ui/CodeMirror" +import { EditorToolbar } from "~/components/ui/EditorToolbar" +import { Input } from "~/components/ui/Input" +import { Modal } from "~/components/ui/Modal" +import { TagInput } from "~/components/ui/TagInput" +import { UniLink } from "~/components/ui/UniLink" +import { toolbars } from "~/editor" +import { useDate } from "~/hooks/useDate" +import { + Values, + initialEditorState, + useEditorState, +} from "~/hooks/useEdtiorState" +import { useGetState } from "~/hooks/useGetState" +import { useIsMobileLayout } from "~/hooks/useMobileLayout" +import { useSyncOnce } from "~/hooks/useSyncOnce" +import { useUploadFile } from "~/hooks/useUploadFile" +import { showConfetti } from "~/lib/confetti" +import { getDefaultSlug } from "~/lib/default-slug" +import { CSB_SCAN } from "~/lib/env" +import { getSiteLink, 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, pick } from "~/lib/utils" +import { Rendered, renderPageContent } from "~/markdown" +import { checkPageSlug } from "~/models/page.model" +import { + useCreateOrUpdatePage, + useGetPage, + useGetPagesBySiteLite, +} from "~/queries/page" +import { useGetSite } from "~/queries/site" + +const getInputDatetimeValue = (date: Date | string, dayjs: any) => { + const str = dayjs(date).format() + return str.substring(0, ((str.indexOf("T") | 0) + 6) | 0) +} + +export default function SubdomainEditor() { + 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 isPost = searchParams?.get("type") === "post" + + const site = useGetSite(subdomain) + + const [draftKey, setDraftKey] = useState("") + useEffect(() => { + if (subdomain) { + let key + if (!pageId) { + const randomId = nanoid() + key = `draft-${site.data?.characterId}-local-${randomId}` + setDraftKey(key) + queryClient.invalidateQueries([ + "getPagesBySite", + site.data?.characterId, + ]) + router.replace( + `/dashboard/${subdomain}/editor?id=local-${randomId}&type=${searchParams?.get( + "type", + )}`, + ) + } else { + key = `draft-${site.data?.characterId}-${pageId}` + } + setDraftKey(key) + setDefaultSlug( + key + .replace(`draft-${site.data?.characterId}-local-`, "") + .replace(`draft-${site.data?.characterId}-`, ""), + ) + } + }, [ + 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 { data: posts = { pages: [] } } = useGetPagesBySiteLite({ + characterId: site.data?.characterId, + limit: 100, + type: "post", + visibility: PageVisibilityEnum.Published, + }) + + const userTags = useMemo(() => { + const result = new Set() + + if (posts?.pages?.length) { + for (const page of posts.pages) { + for (const post of page.list) { + post.metadata?.content?.tags?.forEach((tag) => { + if (tag !== "post" && tag !== "page") { + result.add(tag) + } + }) + } + } + } + return Array.from(result) + }, [posts.pages]) + + const [visibility, setVisibility] = useState() + + useEffect(() => { + if (page.isSuccess) { + setVisibility(getPageVisibility(page.data || undefined)) + } + }, [page.isSuccess, page.data]) + + const uploadFile = useUploadFile() + + // reset editor state when page changes + useSyncOnce(() => { + useEditorState.setState(initialEditorState) + }) + + const values = useEditorState() + + const [initialContent, setInitialContent] = useState("") + const [defaultSlug, setDefaultSlug] = useState("") + + const getValues = useGetState(values) + const getDraftKey = useGetState(draftKey) + + const updateValue = useCallback( + (key: K, value: Values[K]) => { + if (visibility !== PageVisibilityEnum.Draft) { + setVisibility(PageVisibilityEnum.Modified) + } + + const values = getValues() + const draftKey = getDraftKey() + if (key === "title") { + setDefaultSlug( + getDefaultSlug( + value as string, + draftKey.replace(`draft-${site.data?.characterId}-`, ""), + ), + ) + } + if (key === "slug" && !/^[a-zA-Z0-9\-_]*$/.test(value as string)) { + toast.error( + t( + "Slug can only contain letters, numbers, hyphens, and underscores.", + ), + ) + return + } + const newValues = { ...values, [key]: value } + if (draftKey) { + setStorage(draftKey, { + date: +new Date(), + values: newValues, + isPost: isPost, + }) + queryClient.invalidateQueries([ + "getPagesBySite", + site.data?.characterId, + ]) + } + useEditorState.setState(newValues) + }, + [isPost, queryClient, subdomain, visibility], + ) + + const createOrUpdatePage = useCreateOrUpdatePage() + + const isMobileLayout = useIsMobileLayout() + const [isRendering, setIsRendering] = useState(false) + + const savePage = async (published: boolean) => { + const check = await checkPageSlug({ + slug: values.slug || defaultSlug, + characterId: site.data?.characterId, + noteId: page?.data?.noteId, + }) + if (check) { + toast.error(check) + } else { + const uniqueTags = Array.from(new Set(values.tags.split(","))).join(",") + createOrUpdatePage.mutate({ + ...values, + tags: uniqueTags, + slug: values.slug || defaultSlug, + siteId: subdomain, + ...(visibility === PageVisibilityEnum.Draft + ? {} + : { pageId: `${page?.data?.characterId}-${page?.data?.noteId}` }), + isPost: isPost, + published, + externalUrl: + (values.slug || defaultSlug) && + `${getSiteLink({ + subdomain, + domain: site.data?.metadata?.content?.custom_domain, + })}/${encodeURIComponent(values.slug || defaultSlug)}`, + applications: page.data?.metadata?.content?.sources, + characterId: site.data?.characterId, + }) + } + } + + const [isCheersOpen, setIsCheersOpen] = useState(false) + + useEffect(() => { + if (createOrUpdatePage.isSuccess) { + if (createOrUpdatePage.data?.code === 0) { + 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 (createOrUpdatePage.data.data) { + router.replace( + `/dashboard/${subdomain}/editor?id=${site.data?.characterId}-${ + createOrUpdatePage.data.data + }&type=${searchParams?.get("type")}`, + ) + } + + setIsCheersOpen(true) + showConfetti() + } else { + toast.error("Error: " + createOrUpdatePage.data?.message) + } + } + }, [createOrUpdatePage.isSuccess]) + + useEffect(() => { + if (!page.data || !draftKey) return + setInitialContent(page.data.metadata?.content?.content || "") + useEditorState.setState({ + title: page.data.metadata?.content?.title || "", + publishedAt: page.data.metadata?.content?.date_published, + published: !!page.data.noteId, + excerpt: page.data.metadata?.content?.summary || "", + slug: page.data.metadata?.content?.slug || "", + tags: + page.data.metadata?.content?.tags + ?.filter((tag) => tag !== "post" && tag !== "page") + ?.join(", ") || "", + content: page.data.metadata?.content?.content || "", + }) + setDefaultSlug( + getDefaultSlug( + page.data.metadata?.content?.title || "", + draftKey.replace(`draft-${site.data?.characterId}-`, ""), + ), + ) + }, [page.data, subdomain, draftKey, site.data?.characterId]) + + const [currentScrollArea, setCurrentScrollArea] = useState("") + const [view, setView] = useState() + const [tree, setTree] = useState() + + // preview + + const [parsedContent, setParsedContent] = useState() + + useDebounceEffect( + () => { + const result = renderPageContent(values.content) + setTree(result.tree) + setParsedContent(result) + }, + [values.content], + { + wait: 500, + }, + ) + + const previewRef = useRef(null) + + // editor + const onCreateEditor = useCallback( + (view: EditorView) => { + setView?.(view) + }, + [setView], + ) + + const onChange = useCallback( + (value: string) => { + updateValue("content", value) + }, + [updateValue], + ) + + const handleDropFile = useCallback( + async (file: File) => { + const toastId = toast.loading("Uploading...") + try { + if ( + !file.type.startsWith("image/") && + !file.type.startsWith("audio/") + ) { + throw new Error("You can only upload images and audios") + } + + const { key } = await uploadFile(file) + toast.success("Uploaded!", { + id: toastId, + }) + if (file.type.startsWith("image/")) { + view?.dispatch( + view.state.replaceSelection( + `\n![${file.name.replace(/\.\w+$/, "")}](${key})\n`, + ), + ) + } else if (file.type.startsWith("audio/")) { + const fileArrayBuffer = await file.arrayBuffer() + const fileBuffer = Buffer.from(fileArrayBuffer) + const tags = NodeID3.read(fileBuffer) + const name = tags.title ?? file.name + const artist = tags.artist + const cover = await (async () => { + const image = tags.image + if (!image || typeof image === "string") return image + + const toastId = toast.loading("Uploading cover...") + const { key } = await uploadFile( + new Blob([image.imageBuffer], { type: image.type.name }), + ) + toast.success("Uploaded cover!", { + id: toastId, + }) + return key + })() + view?.dispatch( + view.state.replaceSelection( + `\n