feat: use app router for dashboard

This commit is contained in:
DIYgod 2023-05-08 00:04:22 +01:00
parent d5834c9aef
commit 752b469420
No known key found for this signature in database
47 changed files with 3847 additions and 76 deletions

View File

@ -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 (
<DashboardMain title="Achievements">
<div className="min-w-[270px] max-w-screen-lg flex flex-col space-y-8">
<>
{achievement.data?.list?.map((series) => {
let length = series.groups?.length
if (!length) {
return null
}
return (
<div key={series.info.name}>
<div className="text-lg font-medium mb-4">
{series.info.title}
</div>
<div className="grid grid-cols-3 sm:grid-cols-7 gap-x-2 gap-y-5">
{series.groups?.map((group) => (
<AchievementItem
group={group}
key={group.info.name}
layoutId="achievements"
size={80}
characterId={site.data?.characterId}
isOwner={true}
/>
))}
</div>
</div>
)
})}
</>
</div>
</DashboardMain>
)
}

View File

@ -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 (
<DashboardMain title="Comments">
<div className="min-w-[270px] max-w-screen-lg">
<div className="text-sm text-zinc-500 leading-relaxed">
<p>
{t(
"You can subscribe to comments through an RSS reader to receive timely reminders.",
)}
</p>
<p>
{t("Subscription address:")}{" "}
<UniLink className="text-accent" href={feedUrl} target="_blank">
{feedUrl}
</UniLink>
</p>
</div>
<div className="xlog-comment">
<div className="prose space-y-4 pt-4">
<Virtuoso
className="xlog-comment-list"
useWindowScroll
data={comments.data?.pages}
endReached={() =>
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 (
<div key={comment.transactionHash} className="mt-6">
<div>
{name}{" "}
<Trans
i18nKey="comment on your"
values={{
type: t(type || "", {
ns: "common",
}),
toTitle,
}}
defaults="commented on your {{type}} <tolink>{{toTitle}}</tolink>"
components={{
tolink: (
<UniLink
href={`/api/redirection?characterId=${comment.characterId}&noteId=${comment.noteId}`}
target="_blank"
>
.
</UniLink>
),
}}
ns="dashboard"
/>
:
</div>
<CommentItem
className="mt-6"
comment={comment}
depth={0}
/>
</div>
)
})
}
></Virtuoso>
</div>
</div>
</div>
</DashboardMain>
)
}
const Loader = () => {
const { t } = useTranslation("common")
return (
<div
className="relative mt-4 w-full text-sm text-center py-4"
key={"loading"}
>
{t("Loading")}...
</div>
)
}

View File

@ -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<string>("")
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<string>()
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<PageVisibilityEnum>()
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(
<K extends keyof Values>(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<string>("")
const [view, setView] = useState<EditorView>()
const [tree, setTree] = useState<Root | null>()
// preview
const [parsedContent, setParsedContent] = useState<Rendered | undefined>()
useDebounceEffect(
() => {
const result = renderPageContent(values.content)
setTree(result.tree)
setParsedContent(result)
},
[values.content],
{
wait: 500,
},
)
const previewRef = useRef<HTMLDivElement>(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<audio src="${key}" name="${name}" ${
artist ? `artist="${artist}"` : ""
} ${cover ? `cover="${cover}"` : ""}><audio>\n`,
),
)
} else if (file.type === "text/plain") {
view?.dispatch(view.state.replaceSelection(key))
} else {
throw new Error("Unknown upload file type")
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId })
}
}
},
[uploadFile, view],
)
const computedPosition = useCallback(() => {
let previewChildNodes = previewRef.current?.childNodes[0]?.childNodes
const editorElementList: number[] = []
const previewElementList: number[] = []
if (view?.state && previewChildNodes) {
tree?.children.forEach((child, index) => {
if (
child.position &&
previewChildNodes?.[index] &&
(child as any).tagName !== "style"
) {
if (child.position.start.line > view.state.doc.lines) return
const line = view.state?.doc.line(child.position.start.line)
const block = view.lineBlockAt(line.from)
if (block) {
editorElementList.push(block.top)
previewElementList.push(
(previewChildNodes[index] as HTMLElement).offsetTop,
)
}
}
})
}
return {
editorElementList,
previewElementList,
}
}, [view, tree])
const onScroll = useCallback(
(scrollTop: number, area: string) => {
if (
currentScrollArea === area &&
previewRef.current?.parentElement &&
view
) {
const position = computedPosition()
let selfElement
let selfPosition
let targetElement
let targetPosition
if (area === "preview") {
selfElement = previewRef.current.parentElement
selfPosition = position.previewElementList
targetElement = view.scrollDOM
targetPosition = position.editorElementList
} else {
selfElement = view.scrollDOM
selfPosition = position.editorElementList
targetElement = previewRef.current.parentElement
targetPosition = position.previewElementList
}
let scrollElementIndex = 0
for (let i = 0; i < selfPosition.length; i++) {
if (scrollTop < selfPosition[i]) {
scrollElementIndex = i - 1
break
}
}
// scroll to bottom
if (scrollTop >= selfElement.scrollHeight - selfElement.clientHeight) {
targetElement.scrollTop =
targetElement.scrollHeight - targetElement.clientHeight
return
}
// scroll to position
if (scrollElementIndex >= 0) {
let ratio =
(scrollTop - selfPosition[scrollElementIndex]) /
(selfPosition[scrollElementIndex + 1] -
selfPosition[scrollElementIndex])
targetElement.scrollTop =
ratio *
(targetPosition[scrollElementIndex + 1] -
targetPosition[scrollElementIndex]) +
targetPosition[scrollElementIndex]
}
}
},
[view, computedPosition, currentScrollArea],
)
const onEditorScroll = useCallback(
(scrollTop: number) => {
onScroll(scrollTop, "editor")
},
[onScroll],
)
const onPreviewScroll = useCallback(
(scrollTop: number) => {
onScroll(scrollTop, "preview")
},
[onScroll],
)
const onPreviewButtonClick = useCallback(() => {
window.open(
`/_site/${subdomain}/preview/${draftKey.replace(
`draft-${site.data?.characterId}-`,
"",
)}`,
)
}, [draftKey, subdomain, site.data?.characterId])
const extraProperties = (
<EditorExtraProperties
defaultSlug={defaultSlug}
updateValue={updateValue}
isPost={isPost}
subdomain={subdomain}
userTags={userTags}
/>
)
const discardChanges = useCallback(() => {
if (draftKey) {
delStorage(draftKey)
queryClient.invalidateQueries(["getPagesBySite", site.data?.characterId])
page.remove()
page.refetch()
}
}, [draftKey, site.data?.characterId])
return (
<>
<DashboardMain fullWidth>
{page.isLoading ? (
<div className="flex justify-center items-center min-h-[300px]">
{t("Loading")}...
</div>
) : (
<>
<header
className={`flex justify-between absolute top-0 left-0 right-0 z-25 px-5 h-14 border-b items-center text-sm ${
isMobileLayout ? "w-screen" : undefined
}`}
>
<div
className={`flex items-center overflow-x-auto scrollbar-hide ${
isMobileLayout ? "flex-1" : undefined
}`}
>
<EditorToolbar view={view} toolbars={toolbars}></EditorToolbar>
</div>
{isMobileLayout ? (
<div className="flex items-center space-x-3 w-auto pl-5">
<OptionsButton
visibility={visibility}
savePage={savePage}
published={visibility !== PageVisibilityEnum.Draft}
isRendering={isRendering}
renderPage={setIsRendering}
propertiesWidget={extraProperties}
previewPage={onPreviewButtonClick}
isPost={isPost}
isModified={visibility === PageVisibilityEnum.Modified}
discardChanges={discardChanges}
/>
</div>
) : (
<div className="flex items-center space-x-3 flex-shrink-0">
<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>
<Button isAutoWidth onClick={onPreviewButtonClick}>
{t("Preview")}
</Button>
<PublishButton
save={savePage}
twitterShareUrl={
page.data && site.data
? getTwitterShareUrl({
page: page.data,
site: site.data,
t,
})
: ""
}
published={visibility !== PageVisibilityEnum.Draft}
isSaving={createOrUpdatePage.isLoading}
isDisabled={
visibility !== PageVisibilityEnum.Modified &&
visibility !== PageVisibilityEnum.Draft
}
isPost={isPost}
isModified={visibility === PageVisibilityEnum.Modified}
discardChanges={discardChanges}
/>
</div>
)}
</header>
<div
className={`pt-14 flex w-full ${
isMobileLayout
? "w-screen h-[calc(100vh-4rem)]"
: "min-w-[840px] h-screen "
}`}
>
<div className="h-full overflow-auto w-full">
<div className="h-full mx-auto pt-5 flex flex-col">
<div className="px-5 h-12">
<input
type="text"
name="title"
value={values.title}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
view?.focus()
}
}}
onChange={(e) => updateValue("title", e.target.value)}
className="h-12 ml-1 inline-flex items-center border-none text-3xl font-bold w-full focus:outline-none bg-white"
placeholder={t("Title goes here...") || ""}
/>
</div>
<div className="mt-5 flex-1 flex overflow-hidden">
{isMobileLayout ? (
!isRendering ? (
<CodeMirrorEditor
value={initialContent}
onChange={onChange}
handleDropFile={handleDropFile}
onScroll={onEditorScroll}
// onUpdate={onUpdate}
onCreateEditor={onCreateEditor}
onMouseEnter={() => {
setCurrentScrollArea("editor")
}}
/>
) : (
<PageContent
className={`px-5 overflow-scroll pb-[200px] ${
isMobileLayout ? "" : "w-1/2 "
}`}
parsedContent={parsedContent}
inputRef={previewRef}
onScroll={onPreviewScroll}
onMouseEnter={() => {
setCurrentScrollArea("preview")
}}
></PageContent>
)
) : (
<>
<CodeMirrorEditor
value={initialContent}
onChange={onChange}
handleDropFile={handleDropFile}
onScroll={onEditorScroll}
// onUpdate={onUpdate}
onCreateEditor={onCreateEditor}
onMouseEnter={() => {
setCurrentScrollArea("editor")
}}
/>
<PageContent
className={`px-5 overflow-scroll pb-[200px] ${
isMobileLayout ? "" : "w-1/2 "
}`}
parsedContent={parsedContent}
inputRef={previewRef}
onScroll={onPreviewScroll}
onMouseEnter={() => {
setCurrentScrollArea("preview")
}}
></PageContent>
</>
)}
</div>
</div>
</div>
{!isMobileLayout && (
<EditorExtraProperties
defaultSlug={defaultSlug}
updateValue={updateValue}
isPost={isPost}
userTags={userTags}
subdomain={subdomain}
/>
)}
</div>
</>
)}
</DashboardMain>
<Modal
open={isCheersOpen}
setOpen={setIsCheersOpen}
title={`🎉 ${t("Published!")}`}
>
<div className="p-5">
{t(
"Your post has been securely stored on the blockchain. Now you may want to",
)}
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>
<UniLink
className="text-accent"
href={`${getSiteLink({
subdomain,
domain: site.data?.metadata?.content?.custom_domain,
})}/${encodeURIComponent(values.slug || defaultSlug)}`}
>
{t("View the post")}
</UniLink>
</li>
<li>
<UniLink
className="text-accent"
href={`${CSB_SCAN}/tx/${
page.data?.updatedTransactionHash ||
page.data?.transactionHash
}`}
>
{t("View the transaction")}
</UniLink>
</li>
<li>
<UniLink
className="text-accent"
href={
page.data && site.data
? getTwitterShareUrl({
page: page.data,
site: site.data,
t,
})
: ""
}
>
{t("Share to Twitter")}
</UniLink>
</li>
</ul>
</div>
<div className="h-16 border-t flex items-center px-5">
<Button isBlock onClick={() => setIsCheersOpen(false)}>
{t("Got it, thanks!")}
</Button>
</div>
</Modal>
</>
)
}
const EditorExtraProperties: FC<{
updateValue: <K extends keyof Values>(key: K, value: Values[K]) => void
isPost: boolean
subdomain: string
defaultSlug: string
userTags: string[]
}> = memo(({ isPost, updateValue, subdomain, defaultSlug, userTags }) => {
const values = useEditorState(
(state) => pick(state, ["publishedAt", "slug", "excerpt", "tags"]),
shallow,
)
const date = useDate()
const { t } = useTranslation("dashboard")
const site = useGetSite(subdomain)
return (
<div className="h-full overflow-auto flex-shrink-0 w-[280px] border-l bg-zinc-50 p-5 space-y-5">
<div>
<Input
type="datetime-local"
label={t("Publish at") || ""}
isBlock
name="publishAt"
id="publishAt"
value={getInputDatetimeValue(values.publishedAt, date.dayjs)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
try {
const value = date.inLocalTimezone(e.target.value).toISOString()
updateValue("publishedAt", value)
} catch (error) {}
}}
help={t(
`This ${
isPost ? "post" : "page"
} will be accessible from this time`,
)}
/>
</div>
<div>
<Input
name="slug"
value={values.slug}
placeholder={defaultSlug}
label={t(`${isPost ? "Post" : "Page"} slug`) || ""}
id="slug"
isBlock
onChange={(e: ChangeEvent<HTMLInputElement>) =>
updateValue("slug", e.target.value)
}
help={
<>
{(values.slug || defaultSlug) && (
<>
{t(`This ${isPost ? "post" : "page"} will be accessible at`)}{" "}
<UniLink
href={`${getSiteLink({
subdomain,
domain: site.data?.metadata?.content?.custom_domain,
})}/${encodeURIComponent(values.slug || defaultSlug)}`}
className="hover:underline"
>
{getSiteLink({
subdomain,
domain: site.data?.metadata?.content?.custom_domain,
noProtocol: true,
})}
/{encodeURIComponent(values.slug || defaultSlug)}
</UniLink>
</>
)}
</>
}
/>
</div>
<div>
<Input
name="tags"
value={values.tags}
label={t("Tags") || ""}
id="tags"
isBlock
renderInput={(props) => (
<TagInput
{...props}
userTags={userTags}
onTagChange={(value: string) => updateValue("tags", value)}
/>
)}
/>
</div>
<div>
<Input
label={t("Excerpt") || ""}
isBlock
name="excerpt"
id="excerpt"
value={values.excerpt}
multiline
rows={5}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
updateValue("excerpt", e.target.value)
}}
help={t("Leave it blank to use auto-generated excerpt")}
/>
</div>
</div>
)
})
EditorExtraProperties.displayName = "EditorExtraProperties"

View File

@ -0,0 +1,176 @@
"use client"
import { useEffect, useState } from "react"
import { CharacterFloatCard } from "~/components/common/CharacterFloatCard"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { Avatar } from "~/components/ui/Avatar"
import { Image } from "~/components/ui/Image"
import { UniLink } from "~/components/ui/UniLink"
import { useDate } from "~/hooks/useDate"
import { getSiteLink } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import { getStorage, setStorage } from "~/lib/storage"
import { cn } from "~/lib/utils"
import { useGetPagesBySite } from "~/queries/page"
import { useGetSite } from "~/queries/site"
function SiteAvatar({ siteId }: { siteId: string }) {
const site = useGetSite(siteId)
return (
<div className="inline-block w-10 h-10 relative">
<CharacterFloatCard siteId={siteId}>
<UniLink
href={getSiteLink({
subdomain: siteId,
})}
>
<Avatar
images={site.data?.metadata?.content?.avatars || []}
name={site.data?.metadata?.content?.name || ""}
size={40}
/>
</UniLink>
</CharacterFloatCard>
</div>
)
}
export default function EventsPage() {
const date = useDate()
const { t } = useTranslation("dashboard")
const pages = useGetPagesBySite({
type: "post",
characterId: 50153,
limit: 100,
})
const [latestEventRead, setLatestEventRead] = useState<Date>()
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
setLatestEventRead(new Date(getStorage("latestEventRead")?.value || 0))
setStorage("latestEventRead", {
value: new Date().toISOString(),
})
}, [])
pages.data?.pages[0]?.list.forEach((item) => {
item.metadata?.content?.frontMatter?.Winners
})
return (
<DashboardMain title="Events">
<div className="min-w-[270px] max-w-screen-xl flex flex-col xl:flex-row space-y-8 xl:space-y-0">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
{pages.data?.pages[0]?.list.map((item) => {
let status
if (item.metadata?.content?.frontMatter?.EndTime < new Date()) {
status = "Ended"
} else if (
item.metadata?.content?.frontMatter?.StartTime > new Date()
) {
status = "Upcoming"
} else {
status = "Ongoing"
}
let isUnread = false
if (latestEventRead && new Date(item.createdAt) > latestEventRead) {
isUnread = true
}
return (
<div
className={cn("bg-slate-100 rounded-lg relative", {
"opacity-70": status === "Ended",
})}
key={item.transactionHash}
>
{isUnread && (
<div>
<span className="absolute -left-2 -top-2 bg-red-500 rounded-full w-4 h-4"></span>
</div>
)}
<div className="flex flex-col p-7 justify-between h-full">
<div>
<div className="font-bold mb-4">
<span
className={cn(
"border py-1 px-4 rounded-full text-white",
{
"bg-gray-500": status === "Ended",
"bg-green-500": status === "Upcoming",
"bg-yellow-500": status === "Ongoing",
},
)}
>
{t(status)}
</span>
</div>
{item.metadata?.content?.cover && (
<div className="w-full h-24 mb-4">
<Image
className="object-cover rounded"
alt="cover"
fill={true}
src={item.metadata?.content?.cover}
></Image>
</div>
)}
<div className="font-bold text-xl text-zinc-800 leading-tight">
{item.metadata?.content?.title}
</div>
</div>
<div className="mt-4 space-y-4 text-sm">
<div>
<span className="font-bold">{t("Date")}:</span>{" "}
{date.formatDate(
item.metadata?.content?.frontMatter?.StartTime,
"lll",
isMounted ? undefined : "America/Los_Angeles",
)}{" "}
-{" "}
{date.formatDate(
item.metadata?.content?.frontMatter?.EndTime,
"lll",
isMounted ? undefined : "America/Los_Angeles",
)}
</div>
<div>
<span className="font-bold">{t("Prize")}:</span>{" "}
{item.metadata?.content?.frontMatter?.Prize}
</div>
{item.metadata?.content?.frontMatter?.Winners?.map && (
<div>
<span className="font-bold">{t("Winners")}:</span>
<div className="flex items-center space-x-2 mt-2">
{item.metadata?.content?.frontMatter?.Winners?.map?.(
(winner: string) => (
<SiteAvatar key={winner} siteId={winner} />
),
)}
</div>
</div>
)}
</div>
<UniLink
className="mt-6 font-bold flex items-center leading-none"
href={
item.metadata?.content?.frontMatter?.ExtraLink ||
`/api/redirection?characterId=${item.characterId}&noteId=${item.noteId}`
}
>
{t("Learn more")}{" "}
<i className="icon-[mingcute--right-line] text-xl ml-1" />
</UniLink>
</div>
</div>
)
})}
</div>
</div>
</DashboardMain>
)
}

View File

@ -0,0 +1,119 @@
"use client"
import type { NoteMetadata } from "crossbell.js"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { toast } from "react-hot-toast"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { ImportPreview } from "~/components/dashboard/ImportPreview"
import { Button } from "~/components/ui/Button"
import { Input } from "~/components/ui/Input"
import { getSiteLink } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import { readFiles } from "~/lib/read-files"
import { usePostNotes } from "~/queries/page"
import { useGetSite } from "~/queries/site"
export default function ImportMarkdownPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const site = useGetSite(subdomain)
const { t } = useTranslation("dashboard")
const form = useForm({
defaultValues: {
type: "post",
files: [],
},
})
const postNotes = usePostNotes()
const [notes, setNotes] = useState<NoteMetadata[]>()
const handleSubmit = form.handleSubmit(async (values) => {
if (notes?.length && site.data?.handle && site.data.characterId) {
postNotes.mutate({
siteId: site.data.handle,
characterId: site.data.characterId,
notes,
})
}
})
form.register("files", {
onChange: async (e) => {
const files = await readFiles(e.target.files)
const notes = files.map((file) => {
return {
title: file.title,
content: file.content,
tags: ["post", ...file.tags],
sources: ["xlog"],
attributes: [
{
trait_type: "xlog_slug",
value: file.slug,
},
],
date_published: file.date_published,
external_urls: [
`${getSiteLink({
subdomain,
domain: site.data?.metadata?.content?.custom_domain,
})}/${encodeURIComponent(file.slug)}`,
],
}
})
setNotes(notes)
},
})
useEffect(() => {
if (postNotes.isSuccess) {
form.reset()
toast.success("Notes imported successfully")
}
}, [postNotes.isSuccess, form])
return (
<DashboardMain title="Import from Markdown files">
<form onSubmit={handleSubmit}>
<div className="min-w-[270px] max-w-screen-lg flex flex-col space-y-4">
<Input
className="py-1"
label={t(`Select Markdown Files`) || ""}
id="notes"
type="file"
accept=".md"
multiple={true}
help={t(
"Please select .md files, multiple files are supported, and front matter is supported.",
)}
{...form.register("files", {})}
/>
<div>
<div className="form-label">{t("Preview")}</div>
{notes?.length ? (
notes?.map((note) => (
<ImportPreview key={note.title} note={note} />
))
) : (
<div className="text-gray-500">{t("No files chosen")}</div>
)}
</div>
<div>
<Button
isAutoWidth={true}
type="submit"
isLoading={site.isLoading || postNotes.isLoading}
disabled={!notes?.length}
>
{t("Import")}
</Button>
</div>
</div>
</form>
</DashboardMain>
)
}

View File

@ -0,0 +1,111 @@
"use client"
import type { NoteMetadata } from "crossbell.js"
import { useParams } from "next/navigation"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { toast } from "react-hot-toast"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { ImportPreview } from "~/components/dashboard/ImportPreview"
import { Button } from "~/components/ui/Button"
import { getSiteLink } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import { useCheckMirror, useGetMirrorXyz, usePostNotes } from "~/queries/page"
import { useGetSite } from "~/queries/site"
export default function ImportMarkdownPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const site = useGetSite(subdomain)
const { t } = useTranslation("dashboard")
const form = useForm({
defaultValues: {
type: "post",
},
})
const postNotes = usePostNotes()
const mirrorXyz = useGetMirrorXyz({
address: site.data?.owner,
})
const checkMirror = useCheckMirror(site.data?.characterId)
const notes = mirrorXyz?.data?.map((note) => ({
title: note.title,
content: note.content,
tags: ["post", ...note.tags],
sources: ["xlog"],
attributes: [
{
trait_type: "xlog_slug",
value: note.slug,
},
],
date_published: note.date_published,
external_urls: [
`${getSiteLink({
subdomain,
domain: site.data?.metadata?.content?.custom_domain,
})}/${encodeURIComponent(note.slug)}`,
...note.external_urls,
],
}))
const handleSubmit = form.handleSubmit(async (values) => {
if (notes?.length && site.data?.handle && site.data.characterId) {
postNotes.mutate({
siteId: site.data.handle,
characterId: site.data.characterId,
notes: notes,
})
}
})
useEffect(() => {
if (postNotes.isSuccess) {
form.reset()
toast.success("Notes imported successfully")
}
}, [postNotes.isSuccess, form])
return (
<DashboardMain title="Import from Mirror.xyz">
{checkMirror?.data ? (
<form onSubmit={handleSubmit}>
<div className="min-w-[270px] max-w-screen-lg flex flex-col space-y-4">
<div>
<div className="form-label">
{t("Preview your Mirror.xyz entries")}
</div>
{notes?.length ? (
notes?.map((note: NoteMetadata) => (
<ImportPreview key={note.title} note={note} />
))
) : (
<div className="text-gray-500">{t("No entries")}</div>
)}
</div>
<div>
<Button
isAutoWidth={true}
type="submit"
isLoading={
site.isLoading || mirrorXyz.isLoading || postNotes.isLoading
}
disabled={!notes?.length}
>
{t("Import")}
</Button>
</div>
</div>
</form>
) : (
<div className="text-gray-500">
{t(
"You have already imported them, please enter the post page to create a new post!",
)}
</div>
)}
</DashboardMain>
)
}

View File

@ -0,0 +1,51 @@
"use client"
import { usePathname } from "next/navigation"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { Image } from "~/components/ui/Image"
import { UniLink } from "~/components/ui/UniLink"
import { useTranslation } from "~/lib/i18n/client"
export default function ImportPage() {
const pathname = usePathname()
const { t } = useTranslation("dashboard")
const options = [
{
name: "Markdown files",
path: "/markdown",
icon: "markdown.svg",
},
{
name: "Mirror.xyz",
path: "/mirror",
icon: "mirror.xyz.svg",
},
]
return (
<DashboardMain title="Import">
<div className="min-w-[270px] max-w-screen-lg flex flex-col space-y-4">
{options.map((option) => (
<UniLink
className="prose p-6 bg-slate-100 rounded-lg relative flex items-center"
key={option.name}
href={pathname + option.path}
>
<span className="w-8 h-8 mr-4">
<Image
fill
src={`/assets/${option.icon}`}
alt={option.name}
></Image>
</span>
<span className="font-medium">
{t(`Import from ${option.name}`)}
</span>
</UniLink>
))}
</div>
</DashboardMain>
)
}

View File

@ -0,0 +1,390 @@
"use client"
import { useParams, usePathname } from "next/navigation"
import React, { useEffect } from "react"
import { useTranslation } from "react-i18next"
import { useAccountState, useConnectModal } from "@crossbell/connect-kit"
import {
useNotifications,
useShowNotificationModal,
} from "@crossbell/notification"
import { ConnectButton } from "~/components/common/ConnectButton"
import { Logo } from "~/components/common/Logo"
import { SEOHead } from "~/components/common/SEOHead"
import { DashboardSidebar } from "~/components/dashboard/DashboardSidebar"
import { DashboardTopbar } from "~/components/dashboard/DashboardTopbar"
import { Avatar } from "~/components/ui/Avatar"
import { UniLink } from "~/components/ui/UniLink"
import { useIsMobileLayout } from "~/hooks/useMobileLayout"
import { useUserRole } from "~/hooks/useUserRole"
import { APP_NAME, DISCORD_LINK } from "~/lib/env"
import { getSiteLink } from "~/lib/helpers"
import { toGateway } from "~/lib/ipfs-parser"
import { getStorage } from "~/lib/storage"
import { cn } from "~/lib/utils"
import { useGetPagesBySite } from "~/queries/page"
import { useGetSite } from "~/queries/site"
export default function DashboardLayout({
children,
title,
}: {
children: React.ReactNode
title: string
}) {
const params = useParams()
const subdomain = params?.subdomain as string
const site = useGetSite(subdomain)
const userRole = useUserRole(subdomain)
const [ssrReady, account] = useAccountState(({ ssrReady, computed }) => [
ssrReady,
computed.account,
])
const connectModal = useConnectModal()
const [ready, setReady] = React.useState(false)
const [hasPermission, setHasPermission] = React.useState(false)
const { t } = useTranslation("dashboard")
const isMobileLayout = useIsMobileLayout()
const pathname = usePathname()
useEffect(() => {
if (ssrReady) {
if (!account) {
setReady(false)
setHasPermission(false)
connectModal.show()
} else if (userRole.isSuccess) {
setReady(true)
if (userRole.data) {
setHasPermission(true)
} else {
setHasPermission(false)
}
}
}
}, [ssrReady, userRole.isSuccess, userRole.data, account, connectModal])
const showNotificationModal = useShowNotificationModal()
const { isAllRead } = useNotifications()
const pages = useGetPagesBySite({
type: "post",
characterId: 50153,
limit: 1,
})
const [isEventsAllRead, setIsEventsAllRead] = React.useState(true)
const latestEventRead = getStorage("latestEventRead")?.value || 0
useEffect(() => {
if (pages.isSuccess) {
if (
new Date(pages.data.pages[0].list?.[0].createdAt) >
new Date(latestEventRead)
) {
setIsEventsAllRead(false)
} else {
setIsEventsAllRead(true)
}
}
}, [pages.isSuccess, pages.data?.pages, latestEventRead])
const links: {
href?: string
onClick?: () => void
isActive: (ctx: { href: string; pathname: string | null }) => boolean
icon: React.ReactNode
text: string
}[] = [
{
href: `/dashboard/${subdomain}`,
isActive: ({ href, pathname }) => href === pathname,
icon: "icon-[mingcute--grid-line]",
text: "Dashboard",
},
{
href: `/dashboard/${subdomain}/posts`,
isActive: ({ href, pathname }) => href === pathname,
icon: "icon-[mingcute--news-line]",
text: "Posts",
},
{
href: `/dashboard/${subdomain}/pages`,
isActive: ({ href, pathname }) => href === pathname,
icon: "icon-[mingcute--file-line]",
text: "Pages",
},
{
href: `/dashboard/${subdomain}/comments`,
isActive: ({ href, pathname }) => href === pathname,
icon: "icon-[mingcute--comment-line]",
text: "Comments",
},
{
onClick: showNotificationModal,
isActive: ({ href, pathname }) => href === pathname,
icon: isAllRead
? "icon-[mingcute--notification-line]"
: "icon-[mingcute--notification-fill]",
text: isAllRead ? "Notifications" : "Unread notifications",
},
{
href: `/dashboard/${subdomain}/import`,
isActive: ({ pathname }) =>
!!pathname?.startsWith(`/dashboard/${subdomain}/import`),
icon: "icon-[mingcute--file-import-line]",
text: "Import",
},
{
href: `/dashboard/${subdomain}/events`,
isActive: ({ href, pathname }) => href === pathname,
icon: isEventsAllRead
? "icon-[mingcute--flag-4-line]"
: "icon-[mingcute--flag-4-fill]",
text: isEventsAllRead ? "Events" : "New Events",
},
{
href: `/dashboard/${subdomain}/achievements`,
isActive: ({ href, pathname }) => href === pathname,
icon: "icon-[mingcute--trophy-line]",
text: "Achievements",
},
{
href: `/dashboard/${subdomain}/tokens`,
isActive: ({ href, pathname }) => href === pathname,
icon: "icon-[mingcute--pig-money-line]",
text: "Tokens",
},
{
href: `/dashboard/${subdomain}/settings/general`,
isActive: ({ pathname }) =>
!!pathname?.startsWith(`/dashboard/${subdomain}/settings`),
icon: "icon-[mingcute--settings-3-line]",
text: "Settings",
},
]
return ready ? (
hasPermission ? (
<>
<SEOHead title={t(title) || ""} siteName={APP_NAME} />
{site?.data?.metadata?.content?.css && (
<link
type="text/css"
rel="stylesheet"
href={
"data:text/css;base64," +
Buffer.from(toGateway(site.data.metadata?.content?.css)).toString(
"base64",
)
}
/>
)}
<div className="flex h-screen">
{isMobileLayout ? (
<DashboardTopbar
userWidget={
<div className="mb-2 px-2 pt-3 pb-2">
<ConnectButton
left={true}
size="base"
hideNotification={true}
hideName={false}
/>
</div>
}
drawerWidget={(close: any) => (
<>
<div className="flex-1 min-h-0 flex flex-col h-full">
<div className="mb-2 px-5 pt-3 pb-2 text-2xl font-extrabold flex items-center">
<div className="inline-block w-9 h-9 mr-3">
<Logo
type="lottie"
width={36}
height={36}
autoplay={false}
/>
</div>
{"xLog"}
</div>
<div className="px-3 space-y-[2px] text-zinc-500 flex-1 min-h-0 overflow-y-auto">
{links.map((link) => {
const active =
link.href &&
link.isActive({
pathname: pathname,
href: link.href,
})
return (
<div key={link.text} onClick={() => close()}>
<UniLink
href={link.href}
className={cn(
`flex px-4 h-12 items-center rounded-md space-x-2 w-full transition-colors`,
active
? `bg-slate-200 font-medium text-accent`
: `hover:bg-slate-200 hover:bg-opacity-50`,
!true && "justify-center",
)}
onClick={link.onClick}
>
<span className={cn(link.icon, "text-xl")}></span>
<span>{t(link.text)}</span>
</UniLink>
</div>
)
})}
</div>
<div className="flex items-center px-4 flex-col pb-4">
<UniLink
href={DISCORD_LINK}
className="space-x-1 text-zinc-500 hover:text-zinc-800 flex w-full h-12 items-center justify-center transition-colors mb-2"
>
<i className="icon-[mingcute--question-line] text-lg" />
{<span>{t("Need help?")}</span>}
</UniLink>
<UniLink
href={getSiteLink({
subdomain,
})}
className="space-x-2 border rounded-lg bg-slate-100 border-slate-200 text-accent hover:scale-105 transition-transform flex w-full h-12 items-center justify-center"
>
<span className="icon-[mingcute--home-1-line]"></span>
{<span>{t("View Site")}</span>}
</UniLink>
</div>
</div>
</>
)}
>
{(isOpen) => <div>233</div>}
</DashboardTopbar>
) : (
<DashboardSidebar>
{(isOpen) => (
<>
<div className="flex-1 min-h-0 flex flex-col">
<div className="mb-2 px-5 pt-3 pb-2 text-2xl font-extrabold flex items-center">
<div className="inline-block w-9 h-9 mr-3">
<Logo
type="lottie"
width={36}
height={36}
autoplay={false}
/>
</div>
{isOpen && "xLog"}
</div>
{account?.character?.handle &&
subdomain &&
account?.character?.handle !== subdomain && (
<div className="mb-2 px-5 pt-3 pb-2 bg-orange-50 text-center">
<div className="mb-2">
{isOpen && "You are operating"}
</div>
<Avatar
images={site.data?.metadata?.content?.avatars || []}
size={isOpen ? 60 : 40}
name={site.data?.metadata?.content?.name}
/>
{isOpen && (
<span className="flex flex-col justify-center">
<span className="block">
{site.data?.metadata?.content?.name}
</span>
<span className="block text-sm text-zinc-400">
@{site.data?.handle}
</span>
</span>
)}
</div>
)}
<div className="mb-2 px-2 pt-3 pb-2">
<ConnectButton
left={true}
size="base"
hideNotification={true}
hideName={!isOpen}
/>
</div>
<div className="px-3 space-y-[2px] text-zinc-500 flex-1 min-h-0 overflow-y-auto">
{links.map((link) => {
const active =
link.href &&
link.isActive({
pathname: pathname,
href: link.href,
})
return (
<UniLink
href={link.href}
key={link.text}
className={cn(
`flex px-4 h-12 items-center rounded-md space-x-2 w-full transition-colors`,
active
? `bg-slate-200 font-medium text-accent`
: `hover:bg-slate-200 hover:bg-opacity-50`,
!isOpen && "justify-center",
)}
onClick={link.onClick}
>
<span className={cn(link.icon, "text-xl")}></span>
{isOpen && <span>{t(link.text)}</span>}
</UniLink>
)
})}
</div>
</div>
<div className="flex items-center px-4 flex-col pb-4">
<UniLink
href={DISCORD_LINK}
className="space-x-1 text-zinc-500 hover:text-zinc-800 flex w-full h-12 items-center justify-center transition-colors mb-2"
>
<i className="icon-[mingcute--question-line] text-lg" />
{isOpen && <span>{t("Need help?")}</span>}
</UniLink>
<UniLink
href={getSiteLink({
subdomain,
})}
className="space-x-2 border rounded-lg bg-slate-100 border-slate-200 text-accent hover:scale-105 transition-transform flex w-full h-12 items-center justify-center"
>
<span className="icon-[mingcute--home-1-line]"></span>
{isOpen && <span>{t("View Site")}</span>}
</UniLink>
</div>
</>
)}
</DashboardSidebar>
)}
<div
className={`${isMobileLayout ? "pt-16 flex-1" : "flex-1 min-w-0"}`}
>
{children}
</div>
</div>
</>
) : (
<div className="w-screen h-screen flex items-center justify-center flex-col">
<ConnectButton size="base" />
<div className="mt-8">
Sorry, you do not have permission to access the current page.
</div>
<div className="mt-4">
Please switch account or request the owner to set you as the operator.
</div>
<UniLink href="/dashboard" className="mt-4 text-accent">
Take me to my own dashboard
</UniLink>
</div>
)
) : (
<div className="w-screen h-screen flex justify-center items-center">
Loading...
</div>
)
}

View File

@ -0,0 +1,267 @@
"use client"
import { useParams } from "next/navigation"
import { CharacterFloatCard } from "~/components/common/CharacterFloatCard"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { Image } from "~/components/ui/Image"
import { UniLink } from "~/components/ui/UniLink"
import { useDate } from "~/hooks/useDate"
import { CSB_SCAN, DISCORD_LINK, GITHUB_LINK, TWITTER_LINK } from "~/lib/env"
import { getSiteLink } from "~/lib/helpers"
import { Trans, useTranslation } from "~/lib/i18n/client"
import { cn } from "~/lib/utils"
import { useGetShowcase } from "~/queries/home"
import { useGetPagesBySite } from "~/queries/page"
import { useGetSite, useGetStat, useGetTips } from "~/queries/site"
export default function SubdomainIndex() {
const params = useParams()
const subdomain = params?.subdomain as string
const site = useGetSite(subdomain)
const characterId = site.data?.characterId
const stat = useGetStat({
characterId,
})
const date = useDate()
const { t } = useTranslation("dashboard")
const tips = useGetTips({
toCharacterId: characterId,
limit: 1000,
})
const statMap = [
{
icon: "icon-[mingcute--news-line]",
name: "Published posts",
value: stat.data?.notesCount,
url: `/dashboard/${subdomain}/posts`,
},
{
icon: "icon-[mingcute--comment-line]",
name: "Received comments",
value: stat.data?.commentsCount,
url: `/dashboard/${subdomain}/comments`,
},
{
icon: "icon-[mingcute--heart-line]",
name: "Received tips",
value: `${
tips.data?.pages?.[0]?.list
?.map((i) => +i.amount)
.reduce((acr, cur) => acr + cur, 0) ?? 0
} MIRA`,
url: `/dashboard/${subdomain}/tokens`,
},
{
icon: "icon-[mingcute--user-follow-line]",
name: "Followers",
value: stat.data?.subscriptionCount,
url: getSiteLink({
subdomain,
}),
},
{
icon: "icon-[mingcute--eye-line]",
name: "Viewed",
value: stat.data?.viewsCount,
url: getSiteLink({
subdomain,
}),
},
{
icon: "icon-[mingcute--history-line]",
name: "Site Duration",
value:
date.dayjs().diff(date.dayjs(stat.data?.createdAt), "day") +
" " +
t("days"),
url: `${CSB_SCAN}/tx/${stat.data?.createTx}`,
},
]
const pages = useGetPagesBySite({
type: "post",
characterId: 32022,
limit: 4,
})
const showcaseSites = useGetShowcase()
return (
<DashboardMain title="Dashboard" className="max-w-screen-2xl">
<div className="min-w-[270px] flex flex-col xl:flex-row space-y-8 xl:space-y-0">
<div className="flex-1 space-y-8">
<div className="grid gap-4 sm:grid-cols-3 grid-cols-2">
{statMap.map((item) => (
<UniLink
href={item.url}
key={item.name}
className="bg-slate-100 rounded-lg flex justify-center flex-col py-4 px-6"
>
<span>
<i
className={cn(
item.icon,
"inline-block mr-1 text-lg align-middle",
)}
/>
<span className="align-middle">{t(item.name)}</span>
</span>
<span className="font-bold text-2xl">{item.value}</span>
</UniLink>
))}
</div>
<div className="prose p-6 bg-slate-50 rounded-lg relative">
<Trans
i18nKey="hello.welcome"
defaults="<p>👋 Hello there,</p><p>Welcome to use xLog!</p><p>Here are some useful links to get started:</p>"
components={{
p: <p />,
}}
ns="dashboard"
/>
<ul>
<li>
<UniLink
href={getSiteLink({
subdomain,
})}
>
{t("View Site")}
</UniLink>
</li>
<li>
<UniLink href={`/dashboard/${subdomain}/editor?type=post`}>
{t("Create a Post")}
</UniLink>
</li>
<li>
<UniLink href={`/dashboard/${subdomain}/settings/general`}>
{t("Change Site Icon or domain")}
</UniLink>
</li>
</ul>
<Trans
i18nKey="hello.community"
defaults="<p>Join the community to meet friends or build xLog together:</p>"
components={{
p: <p />,
}}
ns="dashboard"
/>
<ul
style={{
marginBottom: 0,
}}
>
<li>
<UniLink href="/activities" target="_blank">
{t("Check out the updates of other bloggers")}
</UniLink>
</li>
<li>
<UniLink href={DISCORD_LINK}>
{t("Join xLog's Discord channel")}
</UniLink>
</li>
<li>
<UniLink href={GITHUB_LINK}>
{t("Participate in the development of xLog")}
</UniLink>
</li>
<li>
<UniLink href={TWITTER_LINK}>
{t("Follow xLog's Twitter")}
</UniLink>
</li>
</ul>
<div></div>
</div>
</div>
<div className="w-full xl:w-[500px] xl:ml-10 space-y-7">
<div className="p-6 bg-slate-50 rounded-lg relative">
<UniLink
href={getSiteLink({ subdomain: "xlog" })}
className="absolute right-0 left-0 top-0 h-16 cursor-pointer"
>
<div className="absolute right-5 top-5 text-sm font-bold">
{t("More")}
</div>
</UniLink>
<h4 className="text-xl font-bold mb-4 leading-none">
{t("xLog News")}
</h4>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
{pages.data?.pages[0]?.list.map((item) => (
<UniLink
href={`${getSiteLink({ subdomain: "xlog" })}/${
item.metadata?.content?.slug
}`}
key={item.transactionHash}
className="bg-slate-100 rounded-lg flex flex-col py-4 px-6"
>
{item.metadata?.content?.cover && (
<div className="w-full h-24">
<Image
className="object-cover rounded"
alt="cover"
fill={true}
src={item.metadata?.content?.cover}
></Image>
</div>
)}
<span className="font-bold text-sm text-zinc-800 leading-tight mt-4">
{item.metadata?.content?.title}
</span>
</UniLink>
))}
</div>
</div>
<div className="p-6 bg-slate-50 rounded-lg relative">
<h4 className="text-xl font-bold mb-4 leading-none">
{t("Meet New Friends")}
</h4>
<ul className="pt-2 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 relative">
{showcaseSites.data?.slice(0, 6)?.map((site) => (
<li className="inline-flex align-middle" key={site.handle}>
<UniLink
href={getSiteLink({
subdomain: site.handle,
})}
className="inline-flex align-middle w-full"
>
<CharacterFloatCard siteId={site.handle}>
<span className="w-14 h-14 inline-block">
<Image
className="rounded-full"
src={
site.metadata.content?.avatars?.[0] ||
"ipfs://bafkreiabgixxp63pg64moxnsydz7hewmpdkxxi3kdsa4oqv4pb6qvwnmxa"
}
alt={site.handle}
width="56"
height="56"
></Image>
</span>
</CharacterFloatCard>
<span className="ml-3 min-w-0 flex-1 justify-center inline-flex flex-col">
<span className="truncate w-full inline-block font-medium">
{site.metadata.content?.name}
</span>
{site.metadata.content?.bio && (
<span className="text-gray-500 text-xs truncate w-full inline-block mt-1">
{site.metadata.content?.bio}
</span>
)}
</span>
</UniLink>
</li>
))}
</ul>
</div>
</div>
</div>
</DashboardMain>
)
}

View File

@ -0,0 +1,7 @@
"use client"
import { PagesManager } from "~/components/dashboard/PagesManager"
export default function SubdomainPages() {
return <PagesManager isPost={false} />
}

View File

@ -0,0 +1,7 @@
"use client"
import { PagesManager } from "~/components/dashboard/PagesManager"
export default function SubdomainPosts() {
return <PagesManager isPost={true} />
}

View File

@ -0,0 +1,119 @@
"use client"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import toast from "react-hot-toast"
import { MonacoEditor } from "~/components/common/Monaco"
import { SettingsLayout } from "~/components/dashboard/SettingsLayout"
import { Button } from "~/components/ui/Button"
import { FieldLabel } from "~/components/ui/FieldLabel"
import { useTranslation } from "~/lib/i18n/client"
import { useGetSite, useUpdateSite } from "~/queries/site"
export default function SettingsCSSPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const updateSite = useUpdateSite()
const site = useGetSite(subdomain)
const { t } = useTranslation("dashboard")
const [css, setCss] = useState("")
const handleSubmit = (e: any) => {
e.preventDefault()
updateSite.mutate({
site: subdomain,
css: css,
})
}
useEffect(() => {
if (updateSite.isSuccess) {
if (updateSite.data?.code === 0) {
toast.success("Saved!")
} else {
toast.error("Failed to update site" + ": " + updateSite.data.message)
}
} else if (updateSite.isError) {
toast.error("Failed to update site")
}
}, [updateSite.isSuccess, updateSite.isError])
const [hasSet, setHasSet] = useState(false)
useEffect(() => {
if (site.isSuccess && site.data && !css && !hasSet) {
setCss(site.data.metadata?.content?.css || "")
setHasSet(true)
}
}, [site.data, site.isSuccess, css, hasSet])
return (
<SettingsLayout title={"Site Settings"}>
<form onSubmit={handleSubmit}>
<div className="">
<div className="p-5 text-zinc-500 bg-zinc-50 mb-5 rounded-lg text-xs space-y-2">
<p className="text-zinc-800 text-sm font-bold">{t("Tips")}:</p>
<p>
{t(
"Scope: These styles will be applied to your entire blog, including this dashboard.",
)}
</p>
<p>
{t(
"Using a browser plugin that can modify page styles, such as Stylebot, can help with debugging.",
)}
</p>
<p>
{t("Support")} <code>ipfs://</code>
</p>
<p>
{t("CSS variables: xLog provides some built-in CSS variables")}
</p>
<p className="pl-2">
<span className="bg-zinc-200 rounded-lg px-2">
--theme-color: #4f46e5;
</span>
</p>
<p className="pl-2">
<span className="bg-zinc-200 rounded-lg px-2">
--header-height: auto;
</span>
</p>
<p className="pl-2">
<span className="bg-zinc-200 rounded-lg px-2">
--banner-bg-color: #000;
</span>
</p>
<p className="pl-2">
<span className="bg-zinc-200 rounded-lg px-2">
--font-fans: ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto,
&quot;Helvetica Neue&quot;, Arial, &quot;Noto Sans&quot;,
sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI
Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color
Emoji&quot;;
</span>
</p>
</div>
<FieldLabel label={t("Custom CSS")} />
<MonacoEditor
className="w-full h-96 border outline-none py-3 rounded-lg inline-flex items-center overflow-hidden"
defaultLanguage="css"
defaultValue={css}
onChange={(value) => setCss(value || "")}
options={{
fontSize: 14,
minimap: { enabled: false },
}}
/>
</div>
<div className="mt-5">
<Button type="submit" isLoading={updateSite.isLoading}>
{t("Save")}
</Button>
</div>
</form>
</SettingsLayout>
)
}

View File

@ -0,0 +1,299 @@
"use client"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import toast from "react-hot-toast"
import {
useAccountState,
useUpgradeEmailAccountModal,
} from "@crossbell/connect-kit"
import { SettingsLayout } from "~/components/dashboard/SettingsLayout"
import { Button } from "~/components/ui/Button"
import { Input } from "~/components/ui/Input"
import { UniLink } from "~/components/ui/UniLink"
import { useUserRole } from "~/hooks/useUserRole"
import { OUR_DOMAIN } from "~/lib/env"
import { getSiteLink } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import { checkDomain, getSite } from "~/models/site.model"
import { useGetSite, useUpdateSite } from "~/queries/site"
export default function SettingsDomainsPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const updateSite = useUpdateSite()
const site = useGetSite(subdomain)
const userRole = useUserRole(subdomain)
const { t } = useTranslation("dashboard")
const isEmailAccount = useAccountState(
(s) => s.computed.account?.type === "email",
)
const upgradeAccountModal = useUpgradeEmailAccountModal()
const form = useForm({
defaultValues: {
subdomain: "",
custom_domain: "",
},
})
const handleSubmit = form.handleSubmit((values) => {
updateSite.mutate({
site: subdomain,
...(subdomain !== values.subdomain && { subdomain: values.subdomain }),
...(site.data?.metadata?.content?.custom_domain !==
values.custom_domain && {
custom_domain: values.custom_domain,
}),
})
})
const [customDomain, setCustomDomain] = useState("")
form.register("custom_domain", {
onChange: (e) => {
setCustomDomain(e.target.value)
toCheckDomain(e.target.value)
},
})
const customSubdomain = customDomain?.split(".").slice(0, -2).join(".") || ""
useEffect(() => {
if (updateSite.isSuccess) {
if (updateSite.data?.code === 0) {
toast.success("Saved!")
router.replace(
`/dashboard/${
updateSite.variables?.subdomain || updateSite.variables?.site
}/settings/domains`,
)
} else {
toast.error("Failed to update site" + ": " + updateSite.data.message)
}
} else if (updateSite.isError) {
toast.error("Failed to update site")
}
}, [updateSite.isSuccess])
const [domainCheckResult, setDomainCheckResult] = useState<{
isLoading: boolean
data?: boolean
}>({
isLoading: false,
data: true,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
const toCheckDomain = (custom: string) => {
if (custom) {
setDomainCheckResult({
isLoading: true,
})
checkDomain(custom, form.getValues().subdomain).then((r) =>
setDomainCheckResult({
isLoading: false,
data: r,
}),
)
}
}
const [subdomainCheckResult, setSubdomainCheckResult] = useState<{
isLoading: boolean
data?: boolean
}>({
isLoading: false,
data: true,
})
const toCheckSubdomain = (subdomain: string) => {
if (subdomain) {
setSubdomainCheckResult({
isLoading: true,
})
const subdomainRegex = /^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/i
if (!subdomainRegex.test(subdomain)) {
setSubdomainCheckResult({
isLoading: false,
data: false,
})
} else {
getSite(subdomain).then((r) =>
setSubdomainCheckResult({
isLoading: false,
data: !r,
}),
)
}
}
}
const [subdomainChanged, setSubdomainChanged] = useState(false)
form.register("subdomain", {
onChange: (e) => {
if (e.target.value !== site.data?.handle) {
toCheckSubdomain(e.target.value)
setSubdomainChanged(true)
} else {
setSubdomainChanged(false)
}
},
})
const [hasSet, setHasSet] = useState(false)
useEffect(() => {
if (site.isSuccess && site.data && !hasSet) {
setHasSet(true)
form.setValue("subdomain", site.data.handle || "")
form.setValue(
"custom_domain",
site.data.metadata?.content?.custom_domain || "",
)
setCustomDomain(site.data.metadata?.content?.custom_domain || "")
toCheckDomain(site.data.metadata?.content?.custom_domain || "")
}
}, [form, site.data, site.isSuccess, customDomain, hasSet, toCheckDomain])
return (
<SettingsLayout title={"Site Settings"}>
<form onSubmit={handleSubmit}>
<div>
<Input
id="subdomain"
label={`xLog ${t("subdomain")}`}
addon={`.${OUR_DOMAIN}`}
className="w-28"
{...form.register("subdomain")}
disabled={isEmailAccount || userRole?.data === "operator"}
/>
{subdomainChanged && (
<div className="text-sm mt-2">
{subdomainCheckResult.isLoading ? (
<span>{t("Subdomain Checking")}...</span>
) : (
<span
className={
subdomainCheckResult.data
? "text-green-600"
: "text-red-600"
}
>
{t(
subdomainCheckResult.data
? "Subdomain Available."
: "Subdomain Unavailable.",
)}
</span>
)}
</div>
)}
{isEmailAccount && (
<div className="text-sm text-orange-400 mt-1">
Email users cannot change subdomain/handle.{" "}
<UniLink
className="underline"
href={
getSiteLink({
subdomain: "crossbell-blog",
}) + "/newbie-villa"
}
>
Learn more
</UniLink>{" "}
or{" "}
<span
className="underline cursor-pointer"
onClick={upgradeAccountModal.show}
>
upgrade account
</span>
.
</div>
)}
{userRole.data === "operator" && (
<div className="text-sm text-orange-400 mt-1">
Operators cannot change subdomain/handle. Please contact the site
owner.
</div>
)}
</div>
<div className="mt-5">
<Input
id="custom_domain"
label={t("Custom Domain") || ""}
className="w-64"
{...form.register("custom_domain")}
/>
{customDomain && (
<div className="mt-2 text-xs space-y-2">
<p>
{t(
"Set the following record on your DNS provider to active your custom domain",
)}
:
</p>
<table>
<tbody>
<tr className="border-b">
<th className="text-center p-3">Type</th>
<th className="text-center p-3">Name</th>
<th className="text-center p-3">Value</th>
</tr>
<tr className="border-b">
<td className="text-center p-3">CNAME</td>
<td className="text-center p-3">
{customSubdomain || "@"}
</td>
<td className="text-center p-3">cname.{OUR_DOMAIN}</td>
</tr>
<tr className="border-b">
<td className="text-center p-3">TXT</td>
<td className="text-center p-3">{`_xlog-challenge${
customSubdomain ? `.${customSubdomain}` : customSubdomain
}`}</td>
<td className="text-center p-3">{subdomain}</td>
</tr>
</tbody>
</table>
<div className="text-sm">
{domainCheckResult.isLoading ? (
<span>{t("DNS Checking")}...</span>
) : domainCheckResult.data ? (
<span className="text-green-600">
{t("DNS check passed.")}
</span>
) : (
<span>
<span className="text-red-600">
{t("DNS check failed.")}
</span>
<Button
className="ml-4 font-media"
variant="secondary"
onClick={() => toCheckDomain(customDomain)}
>
{t("Recheck")}
</Button>
</span>
)}
</div>
</div>
)}
</div>
<div className="mt-5">
<Button
type="submit"
isLoading={updateSite.isLoading || domainCheckResult.isLoading}
isDisabled={domainCheckResult?.data === false && !!customDomain}
>
{t("Save")}
</Button>
</div>
</form>
</SettingsLayout>
)
}

View File

@ -0,0 +1,245 @@
"use client"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import toast from "react-hot-toast"
import { SettingsLayout } from "~/components/dashboard/SettingsLayout"
import { Button } from "~/components/ui/Button"
import { ImageUploader } from "~/components/ui/ImageUploader"
import { Input } from "~/components/ui/Input"
import { UniLink } from "~/components/ui/UniLink"
import { Trans, useTranslation } from "~/lib/i18n/client"
import { toIPFS } from "~/lib/ipfs-parser"
import { useGetSite, useUpdateSite } from "~/queries/site"
export default function SiteSettingsGeneralPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const updateSite = useUpdateSite()
const site = useGetSite(subdomain)
const { t } = useTranslation("dashboard")
const form = useForm({
defaultValues: {
icon: "",
banner: undefined,
name: "",
description: "",
ga: "",
ua: "",
} as {
icon: string
banner?: {
address: string
mime_type: string
}
name: string
description: string
ga: string
ua: string
},
})
const handleSubmit = form.handleSubmit((values) => {
updateSite.mutate({
icon: values.icon,
banner: values.banner,
site: subdomain,
name: values.name,
description: values.description,
ga: values.ga,
ua: values.ua,
})
})
useEffect(() => {
if (updateSite.isSuccess) {
if (updateSite.data?.code === 0) {
toast.success("Site updated")
} else {
toast.error("Failed to update site" + ": " + updateSite.data.message)
}
} else if (updateSite.isError) {
toast.error("Failed to update site")
}
}, [updateSite.isSuccess, updateSite.isError])
useEffect(() => {
if (site.data) {
!form.getValues("icon") &&
form.setValue(
"icon",
toIPFS(site.data?.metadata?.content?.avatars?.[0] || ""),
)
!form.getValues("banner") &&
form.setValue(
"banner",
site.data?.metadata?.content?.banners?.[0]
? {
address: toIPFS(
site.data?.metadata?.content?.banners?.[0].address || "",
),
mime_type: site.data?.metadata?.content?.banners?.[0].mime_type,
}
: undefined,
)
!form.getValues("name") &&
form.setValue("name", site.data.metadata?.content?.name || "")
!form.getValues("description") &&
form.setValue("description", site.data.metadata?.content?.bio || "")
!form.getValues("ga") &&
form.setValue("ga", site.data.metadata?.content?.ga || "")
!form.getValues("ua") &&
form.setValue("ua", site.data.metadata?.content?.ua || "")
}
}, [site.data, form])
const [iconUploading, setIconUploading] = useState(false)
const [bannerUploading, setBannerUploading] = useState(false)
return (
<SettingsLayout title="Site Settings">
<form onSubmit={handleSubmit}>
<div className="mt-5">
<label htmlFor="icon" className="form-label">
{t("Icon")}
</label>
<Controller
name="icon"
control={form.control}
render={({ field }) => (
<ImageUploader
id="icon"
className="w-24 h-24 rounded-full"
uploadStart={() => {
setIconUploading(true)
}}
uploadEnd={(key) => {
form.setValue("icon", key as string)
setIconUploading(false)
}}
{...field}
/>
)}
/>
</div>
<div className="mt-5">
<label htmlFor="icon" className="form-label">
{t("Banner")}
</label>
<Controller
name="banner"
control={form.control}
render={({ field }) => (
<ImageUploader
id="banner"
className="max-w-screen-md h-[220px]"
uploadStart={() => {
setBannerUploading(true)
}}
uploadEnd={(key) => {
form.setValue(
"banner",
key as { address: string; mime_type: string },
)
setBannerUploading(false)
}}
withMimeType={true}
hasClose={true}
{...(field as any)}
/>
)}
/>
<div className="text-xs text-gray-400 mt-1">
{t("Supports both pictures and videos.")}
</div>
</div>
<div className="mt-5">
<Input
required
label={t("Name") || ""}
id="name"
{...form.register("name")}
/>
</div>
<div className="mt-5">
<label htmlFor="description" className="form-label">
{t("Description")}
</label>
<Input
multiline
id="description"
className="input is-block"
rows={2}
{...form.register("description")}
/>
</div>
<div className="mt-5">
<Input
id="ga"
{...form.register("ga")}
prefix="G-"
label="Google Analytics"
help={
<p>
<Trans i18nKey="Integrate Google Analytics" ns="dashboard">
Integrate Google Analytics into your site. You can follow the
instructions{" "}
<UniLink
className="underline"
href="https://support.google.com/analytics/answer/9539598"
>
here
</UniLink>{" "}
to find your Measurement ID.
</Trans>
</p>
}
/>
</div>
<div className="mt-5">
<Input
id="ua"
{...form.register("ua")}
label="Umami Cloud Analytics"
help={
<p>
<Trans i18nKey="Integrate Umami Cloud Analytics" ns="dashboard">
Integrate Umami Cloud Analytics into your site. You can follow
the instructions{" "}
<UniLink
className="underline"
href="https://umami.is/docs/collect-data"
>
here
</UniLink>{" "}
to find your Website ID.
</Trans>
</p>
}
/>
</div>
<div className="mt-5">
<Button
type="submit"
isLoading={updateSite.isLoading}
isDisabled={iconUploading || bannerUploading}
>
{t("Save")}
</Button>
</div>
</form>
{/* <div className="mt-14 border-t pt-8">
<h3 className="text-red-500 text-lg mb-5">Danger Zone</h3>
<form>
<Button variantColor="red" type="submit">
Delete Site
</Button>
</form>
</div> */}
</SettingsLayout>
)
}

View File

@ -0,0 +1,197 @@
"use client"
import equal from "fast-deep-equal"
import { nanoid } from "nanoid"
import { useParams } from "next/navigation"
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react"
import toast from "react-hot-toast"
import { ReactSortable } from "react-sortablejs"
import { SettingsLayout } from "~/components/dashboard/SettingsLayout"
import { Button } from "~/components/ui/Button"
import { Input } from "~/components/ui/Input"
import { useTranslation } from "~/lib/i18n/client"
import { SiteNavigationItem } from "~/lib/types"
import { useGetSite, useUpdateSite } from "~/queries/site"
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 }) => {
const { t } = useTranslation("dashboard")
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"
>
<i className="icon-[mingcute--dot-grid-fill]" />
</button>
</div>
<Input
label={t("Label") || ""}
required
id={`${item.id}-label`}
value={item.label}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
updateItem(item.id, { label: e.target.value })
}
/>
<Input
label={t("URL") || ""}
required
id={`${item.id}-url`}
type="text"
value={item.url}
pattern="(https?://|/)(.*)?"
title="URL must start with / or http:// or https://"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
updateItem(item.id, { url: e.target.value })
}
/>
<div className="flex items-end relative -top-[5px]">
<Button onClick={() => removeItem(item.id)} variantColor="red">
{t("Remove")}
</Button>
</div>
</div>
)
}
export default function SiteSettingsNavigationPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const updateSite = useUpdateSite()
const site = useGetSite(subdomain)
const { t } = useTranslation("dashboard")
const [items, setItems] = useState<SiteNavigationItem[]>([])
const itemsModified = useMemo(() => {
if (!site.isSuccess) return false
return !equal(items, site.data?.metadata?.content?.navigation)
}, [items, site.data, site.isSuccess])
const updateItem: UpdateItem = (id, newItem) => {
setItems((items) => {
return items.map((item) => {
if (item.id === id) {
return { ...item, ...newItem }
}
return item
})
})
}
const newEmptyItem = () => {
setItems((items) => [...items, { id: nanoid(), label: "", url: "" }])
}
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (site.data?.handle) {
updateSite.mutate({
site: site.data?.handle,
navigation: items,
})
}
}
useEffect(() => {
if (updateSite.isSuccess) {
if (updateSite.data?.code === 0) {
toast.success("Saved")
} else {
toast.error("Failed to save" + ": " + updateSite.data.message)
}
} else if (updateSite.isError) {
toast.error("Failed to save")
}
}, [updateSite.isSuccess, updateSite.isError])
const removeItem: RemoveItem = (id) => {
setItems((items) => items.filter((item) => item.id !== id))
}
const [hasSet, setHasSet] = useState(false)
useEffect(() => {
if (site.data?.metadata?.content?.navigation && !hasSet) {
setHasSet(true)
setItems(site.data?.metadata?.content?.navigation)
}
}, [site.data?.metadata?.content?.navigation, hasSet])
return (
<SettingsLayout title="Site Settings">
<div className="p-5 text-zinc-500 bg-zinc-50 mb-5 rounded-lg text-xs space-y-2">
<p className="text-zinc-800 text-sm font-bold">{t("Tips")}:</p>
<p>
<span className="text-zinc-800 font-medium">
{t("xLog provides some out-of-the-box built-in pages")}:
</span>
</p>
<p>
<span className="text-zinc-800">- {t("Home page")}:</span>{" "}
<span className="bg-zinc-200 rounded-lg px-2">/</span>
</p>
<p>
<span className="text-zinc-800">- {t("Archives page")}:</span>{" "}
<span className="bg-zinc-200 rounded-lg px-2">/archives</span>
</p>
<p>
<span className="text-zinc-900">- {t("Tag page")}:</span>{" "}
<span className="bg-zinc-200 rounded-lg px-2">/tag/[tag]</span>
</p>
<p>
<span className="text-zinc-900">- {t("NFT Showcase page")}:</span>{" "}
<span className="bg-zinc-200 rounded-lg px-2">/nft</span>
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="bg-zinc-50 rounded-lg overflow-hidden">
{items.length === 0 && (
<div className="text-center text-zinc-500 p-5">
No navigation items yet
</div>
)}
<ReactSortable list={items} setList={setItems} handle=".drag-handle">
{items.map((item) => {
return (
<SortableNavigationItem
key={item.id}
item={item}
updateItem={updateItem}
removeItem={removeItem}
/>
)
})}
<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
type="submit"
isLoading={updateSite.isLoading}
isDisabled={!itemsModified}
>
{t("Save")}
</Button>
<Button variant="secondary" onClick={newEmptyItem}>
{t("New Item")}
</Button>
</div>
</form>
</SettingsLayout>
)
}

View File

@ -0,0 +1,261 @@
"use client"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import toast from "react-hot-toast"
import {
useAccountState,
useUpgradeEmailAccountModal,
} from "@crossbell/connect-kit"
import { Dialog } from "@headlessui/react"
import { CharacterCard } from "~/components/common/CharacterCard"
import { SettingsLayout } from "~/components/dashboard/SettingsLayout"
import { Button } from "~/components/ui/Button"
import { Input } from "~/components/ui/Input"
import { UniLink } from "~/components/ui/UniLink"
import { useUserRole } from "~/hooks/useUserRole"
import { getSiteLink } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import {
useAddOperator,
useGetOperators,
useGetSite,
useRemoveOperator,
} from "~/queries/site"
type RemoveItem = (operator: string) => void
const SortableNavigationItem: React.FC<{
item: string
removeItem: RemoveItem
isLoading: boolean
disabled?: boolean
}> = ({ item, removeItem, isLoading, disabled }) => {
const { t } = useTranslation("dashboard")
return (
<div className="flex space-x-5 border-b p-5 bg-zinc-50 last:border-0 items-center">
<div className="text-sm space-y-4">
<div>
{t("Address")}: {item}
</div>
<div>{t("Character")}:</div>
<CharacterCard
address={item}
open={true}
hideFollowButton={true}
style="flat"
/>
</div>
<div className="flex items-end relative -top-[5px]">
<Button
onClick={() => removeItem(item)}
variantColor="red"
isLoading={isLoading}
isDisabled={disabled}
>
{t("Remove")}
</Button>
</div>
</div>
)
}
export default function SettingsOperatorPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const addOperator = useAddOperator()
const removeOperator = useRemoveOperator()
const site = useGetSite(subdomain)
const operators = useGetOperators({
characterId: site.data?.characterId,
})
const isEmailAccount = useAccountState(
(s) => s.computed.account?.type === "email",
)
const upgradeAccountModal = useUpgradeEmailAccountModal()
const userRole = useUserRole(subdomain)
const { t } = useTranslation("dashboard")
const [items, setItems] = useState<string[]>([])
const newEmptyItem = () => {
setIsOpen(true)
setAddress("")
}
useEffect(() => {
if (addOperator.isSuccess) {
toast.success("Operator added")
} else if (addOperator.isError) {
toast.error("Failed to add operator")
}
setIsOpen(false)
}, [addOperator.isSuccess, addOperator.isError])
useEffect(() => {
if (removeOperator.isSuccess) {
toast.success("Operator removed")
} else if (removeOperator.isError) {
toast.error("Failed to remove operator")
}
}, [removeOperator.isSuccess, removeOperator.isError])
const removeItem: RemoveItem = (operator) => {
removeOperator.mutate({
characterId: site.data?.characterId,
operator: operator,
})
}
const addItem = () => {
if (address) {
addOperator.mutate({
characterId: site.data?.characterId,
operator: address,
})
}
}
useEffect(() => {
setItems(operators.data?.list.map((o) => o.operator) || [])
}, [operators.data])
const [isOpen, setIsOpen] = useState(false)
const [address, setAddress] = useState("")
return (
<SettingsLayout title="Site Settings">
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="fixed z-10 inset-0 overflow-y-auto"
>
<div className="flex items-center justify-center min-h-screen">
<Dialog.Overlay className="fixed inset-0 bg-black opacity-30" />
<div className="relative bg-white rounded-lg max-w-md w-full mx-auto">
<Dialog.Title className="px-5 h-12 flex items-center border-b">
{t("New operator")}
</Dialog.Title>
<div className="p-5">
<Input
className="w-full"
label={t("Operator Address") || ""}
required
onChange={(e) => {
setAddress(e.target.value)
}}
/>
<div className="form-label mt-5">
{t("Operator Character Check")}
</div>
<div>
<CharacterCard
address={address}
open={true}
hideFollowButton={true}
style="flat"
/>
</div>
</div>
<div className="h-16 border-t flex items-center px-5">
<Button
isLoading={addOperator.isLoading}
isDisabled={!address}
onClick={addItem}
>
{t("Save")}
</Button>
</div>
</div>
</div>
</Dialog>
<div className="p-5 text-zinc-500 bg-orange-50 mb-5 rounded-lg text-sm space-y-2">
<p className="text-zinc-800 text-sm font-bold"> {t("Warning")}:</p>
<p>
<span className="text-zinc-800">
{isEmailAccount && (
<span>
Email users cannot set operators.{" "}
<UniLink
className="underline"
href={
getSiteLink({
subdomain: "crossbell-blog",
}) + "/newbie-villa"
}
>
Learn more
</UniLink>{" "}
or{" "}
<span
className="underline cursor-pointer"
onClick={upgradeAccountModal.show}
>
upgrade account
</span>
.
</span>
)}
{userRole.data === "operator" && (
<span>
Operators cannot set other operators. Please contact the site
owner.
</span>
)}
{!isEmailAccount && userRole.data !== "operator" && (
<span>
{t(
"Operators have permissions to enter your dashboard, change your settings(excluding xLog subdomain) and post, modify, delete contents on your site.",
)}
</span>
)}
</span>
</p>
</div>
<div
className={
isEmailAccount || userRole.data === "operator"
? `grayscale cursor-not-allowed`
: ""
}
>
<div className="bg-zinc-50 rounded-lg overflow-hidden">
{items.length === 0 && (
<div className="text-center text-zinc-500 p-5">
No operators yet
</div>
)}
{items.map((item, index) => {
return (
<SortableNavigationItem
key={index}
item={item}
removeItem={removeItem}
isLoading={removeOperator.isLoading}
disabled={isEmailAccount || userRole.data === "operator"}
/>
)
})}
<style jsx global>{`
.sortable-ghost {
opacity: 0.4;
}
`}</style>
</div>
<div className="border-t pt-5 mt-10 space-x-3 flex items-center">
<Button
onClick={newEmptyItem}
isDisabled={isEmailAccount || userRole.data === "operator"}
>
{t("Add")}
</Button>
</div>
</div>
</SettingsLayout>
)
}

View File

@ -0,0 +1,237 @@
"use client"
import equal from "fast-deep-equal"
import { nanoid } from "nanoid"
import { useParams } from "next/navigation"
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react"
import toast from "react-hot-toast"
import { ReactSortable } from "react-sortablejs"
import { SettingsLayout } from "~/components/dashboard/SettingsLayout"
import { Platform } from "~/components/site/Platform"
import { Button } from "~/components/ui/Button"
import { Input } from "~/components/ui/Input"
import { UniLink } from "~/components/ui/UniLink"
import { Trans, useTranslation } from "~/lib/i18n/client"
import { useGetSite, useUpdateSite } from "~/queries/site"
type Item = {
identity: string
platform: string
url?: string | undefined
} & {
id: string
}
type UpdateItem = (id: string, newItem: Partial<Item>) => void
type RemoveItem = (id: string) => void
const SortableNavigationItem: React.FC<{
item: Item
updateItem: UpdateItem
removeItem: RemoveItem
}> = ({ item, updateItem, removeItem }) => {
const { t } = useTranslation("dashboard")
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"
>
<i className="icon-[mingcute--dot-grid-fill]" />
</button>
</div>
<Input
label={t("Platform") || ""}
required
id={`${item.id}-platform`}
value={item.platform}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
updateItem(item.id, { platform: e.target.value })
}
/>
<Input
label="Identity"
required
id={`${item.id}-identity`}
type="text"
value={item.identity}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
updateItem(item.id, { identity: e.target.value })
}
/>
<div className="flex items-end pb-2">
<Platform platform={item.platform} username={item.identity}></Platform>
</div>
<div className="flex items-end relative -top-[5px]">
<Button onClick={() => removeItem(item.id)} variantColor="red">
{t("Remove")}
</Button>
</div>
</div>
)
}
export default function SiteSettingsNavigationPage() {
const params = useParams()
const subdomain = params?.subdomain as string
const updateSite = useUpdateSite()
const site = useGetSite(subdomain)
const { t } = useTranslation("dashboard")
const [items, setItems] = useState<Item[]>([])
const itemsModified = useMemo(() => {
if (!site.isSuccess) return false
return !equal(items, site.data?.metadata?.content?.connected_accounts)
}, [items, site.data, site.isSuccess])
const updateItem: UpdateItem = (id, newItem) => {
setItems((items) => {
return items.map((item) => {
if (item.id === id) {
return { ...item, ...newItem }
}
return item
})
})
}
const newEmptyItem = () => {
setItems((items) => [
...items,
{ id: nanoid(), platform: "", identity: "" },
])
}
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (site.data?.handle) {
updateSite.mutate({
site: site.data?.handle,
connected_accounts: items.map(({ id, ...item }) => item),
})
}
}
useEffect(() => {
if (updateSite.isSuccess) {
if (updateSite.data?.code === 0) {
toast.success("Saved")
} else {
toast.error("Failed to save" + ": " + updateSite.data.message)
}
} else if (updateSite.isError) {
toast.error("Failed to save")
}
}, [updateSite.isSuccess, updateSite.isError])
const removeItem: RemoveItem = (id) => {
setItems((items) => items.filter((item) => item.id !== id))
}
const [hasSet, setHasSet] = useState(false)
useEffect(() => {
if (site.data?.metadata?.content?.connected_accounts && !hasSet) {
setHasSet(true)
setItems(
site.data?.metadata?.content?.connected_accounts.map((item) => {
const match = item.match(/:\/\/account:(.*)@(.*)/)
if (match) {
return {
id: nanoid(),
identity: match[1],
platform: match[2],
}
} else {
return {
id: nanoid(),
identity: item,
platform: "",
}
}
}),
)
}
}, [site.data?.metadata?.content?.connected_accounts, hasSet])
return (
<SettingsLayout title="Site Settings">
<div className="p-5 text-zinc-500 bg-zinc-50 mb-5 rounded-lg text-xs space-y-2">
<p className="text-zinc-800 text-sm font-bold">{t("Tips")}:</p>
<p>
<span className="text-zinc-800">{t("social tips.p1")}</span>
</p>
<p>
<span className="text-zinc-800">
<Trans ns="dashboard" i18nKey="social tips.p2">
We support{" "}
<UniLink
href="https://github.com/Crossbell-Box/xLog/blob/dev/src/components/site/Platform.tsx#L7"
className="underline"
>
these platforms
</UniLink>{" "}
with automatic display of logos and links, other platforms will
display a default logo. Please feel free to submit an issue or pr
to us to support more platforms.
</Trans>
</span>
</p>
<p>
<span className="text-zinc-800">
<Trans ns="dashboard" i18nKey="social tips.p3">
You can also connect to Twitter, Telegram Channel, Medium,
Substack and more and automatically sync content on{" "}
<UniLink href="https://xsync.app/" className="underline">
xSync
</UniLink>
. When you have set up the sync there, it will also show here.
</Trans>
</span>
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="bg-zinc-50 rounded-lg overflow-auto">
{items.length === 0 && (
<div className="text-center text-zinc-500 p-5">
No navigation items yet
</div>
)}
<ReactSortable list={items} setList={setItems} handle=".drag-handle">
{items.map((item) => {
return (
<SortableNavigationItem
key={item.id}
item={item}
updateItem={updateItem}
removeItem={removeItem}
/>
)
})}
<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
type="submit"
isLoading={updateSite.isLoading}
isDisabled={!itemsModified}
>
{t("Save")}
</Button>
<Button variant="secondary" onClick={newEmptyItem}>
{t("New Item")}
</Button>
</div>
</form>
</SettingsLayout>
)
}

View File

@ -0,0 +1,155 @@
"use client"
import { useParams } from "next/navigation"
import type { ReactElement } from "react"
import {
useAccountBalance,
useClaimCSBStatus,
useWalletClaimCSBModal,
} from "@crossbell/connect-kit"
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { Button } from "~/components/ui/Button"
import { UniLink } from "~/components/ui/UniLink"
import { MIRA_LINK } from "~/lib/env"
import { getSiteLink } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import { useGetMiraBalance, useGetSite } from "~/queries/site"
export default function TokensPage() {
const params = useParams()
const { t } = useTranslation("dashboard")
const { t: indexT } = useTranslation("index")
const subdomain = params?.subdomain as string
const site = useGetSite(subdomain)
const miraBalance = useGetMiraBalance(site.data?.characterId)
const csbBalance = useAccountBalance()
const claimCSBStatus = useClaimCSBStatus()
const claimCSBModal = useWalletClaimCSBModal()
const tokens = [
{
name: "MIRA",
balance: miraBalance.isLoading
? "Loading..."
: miraBalance.data?.data || 0,
description: (
<>
<p>
{t(
"$MIRA is a valuable token in the Crossbell world, and can be easily exchanged on the Crosschain Bridge and Uniswap.",
)}
</p>
<p>
{t("In the early stage, xLog will use $MIRA to motivate creators.")}
</p>
<p>{t("You can obtain $MIRA through the following ways:")}</p>
<ul className="ml-2">
<li>
1.{" "}
<UniLink
className="underline"
href={`${getSiteLink({
subdomain: "xlog",
})}/creator-incentive-plan`}
>
{t("Creator incentive program.")}
</UniLink>
</li>
<li>
2.{" "}
<UniLink
className="underline"
href={`/dashboard/${subdomain}/events`}
>
{t("Participate in events.")}
</UniLink>
</li>
<li>
3.{" "}
<UniLink className="underline" href={MIRA_LINK}>
{t("Swap from USDC.")}
</UniLink>
</li>
<li>4. {t("Received tips and sponsorships from readers.")}</li>
</ul>
</>
),
buttons: (
<div className="w-fit space-x-4">
<Button onClick={() => window.open(MIRA_LINK)}>
<>{t("Swap to USDC") || ""}</>
</Button>
<UniLink
className="underline text-zinc-500"
href={`${getSiteLink({ subdomain: "atlas" })}/swap-mira-for-usdc`}
>
{t("Swap Tutorial")}
</UniLink>
</div>
),
},
{
name: "CSB",
balance: csbBalance?.balance?.formatted || 0,
description: (
<>
{t(
"This is a token used for interaction on the Crossbell blockchain, which can be claimed for free from the faucet, so there's no need to worry about its balance.",
)}
</>
),
buttons: (
<>
<Button
isDisabled={!claimCSBStatus.isEligibleToClaim}
onClick={() => claimCSBModal.show()}
>
{claimCSBStatus.errorMsg || t("Free Claim")}
</Button>
</>
),
},
{
name: "XLOG",
description: t(
"$XLOG is related to xLog DAO, but it's too early now, please stay tuned.",
),
balance: t("Stay tuned"),
},
]
return (
<DashboardMain title="Tokens">
<div className="min-w-[270px] max-w-screen-lg flex flex-col space-y-8">
<div className="text-sm text-zinc-500 leading-relaxed">
{indexT("features.Earn.description")}
</div>
{tokens.map((token) => {
return (
<div key={token.name} className="space-y-4">
<div className="text-2xl font-medium text-accent">
${token.name}
</div>
<div className="text-zinc-500 text-sm space-y-1">
{token.description}
</div>
<div className="text-lg">
{t("Balance")}:{" "}
<span className="font-bold">{token.balance}</span>
</div>
<div>{token.buttons}</div>
</div>
)
})}
</div>
</DashboardMain>
)
}
TokensPage.getLayout = (page: ReactElement) => {
return <DashboardLayout title="Tokens">{page}</DashboardLayout>
}

View File

@ -0,0 +1,64 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect, useRef } from "react"
import {
useAccountState,
useConnectModal,
useWalletMintNewCharacterModal,
} from "@crossbell/connect-kit"
export default function Dashboard() {
const router = useRouter()
const walletMintNewCharacterModal = useWalletMintNewCharacterModal()
const [ssrReady, account] = useAccountState(({ ssrReady, computed }) => [
ssrReady,
computed.account,
])
const connectModal = useConnectModal()
const isConnectModalShown = useRef(false)
const isMintCharacterModalShown = useRef(false)
useEffect(() => {
if (ssrReady) {
// Wait till SSR is ready
if (!account) {
// Wallet not connected
if (!isConnectModalShown.current) {
// Not shown
isConnectModalShown.current = true
connectModal.show()
} else if (!connectModal.isActive) {
// Shown, but closed by user
router.push("/") // Go back home
}
} else {
// Wallet is connected, wait till site is ready
// Reset connect wallet status to prevent unexpected redirect
isConnectModalShown.current = false
if (!account.character) {
// No character found, prompt to mint one
if (!isMintCharacterModalShown.current) {
// Not shown
isMintCharacterModalShown.current = true
walletMintNewCharacterModal.show()
} else if (!walletMintNewCharacterModal.isActive) {
// Shown, but closed by user
router.push("/") // Go back home
}
} else {
// Already have characters, redirect to primary
router.push(`/dashboard/${account.character.handle}`)
}
}
}
}, [ssrReady, router, walletMintNewCharacterModal, account, connectModal])
return (
<div className="flex items-center justify-center w-full h-60">
Loading...
</div>
)
}

View File

@ -1,4 +1,4 @@
import { useRouter } from "next/router"
import { useParams } from "next/navigation"
import React, { useEffect } from "react"
import { useTranslation } from "react-i18next"
@ -33,8 +33,8 @@ export function DashboardLayout({
children: React.ReactNode
title: string
}) {
const router = useRouter()
const subdomain = router.query.subdomain as string
const params = useParams()
const subdomain = params?.subdomain as string
const site = useGetSite(subdomain)
const userRole = useUserRole(subdomain)

View File

@ -1,7 +1,12 @@
import { nanoid } from "nanoid"
import { Trans, useTranslation } from "next-i18next"
import Link from "next/link"
import { useRouter } from "next/router"
import {
useParams,
usePathname,
useRouter,
useSearchParams,
} from "next/navigation"
import { Fragment, useMemo, useState } from "react"
import { Menu } from "@headlessui/react"
@ -28,16 +33,19 @@ import { PagesManagerMenu } from "./PagesManagerMenu"
export const PagesManager: React.FC<{
isPost: boolean
}> = ({ isPost }) => {
const router = useRouter()
const subdomain = router.query.subdomain as string
const params = useParams()
const subdomain = params?.subdomain as string
const site = useGetSite(subdomain)
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const visibility = useMemo<PageVisibilityEnum>(
() =>
router.query.visibility
? (router.query.visibility as PageVisibilityEnum)
searchParams?.get("visibility")
? (searchParams?.get("visibility") as PageVisibilityEnum)
: PageVisibilityEnum.All,
[router.query.visibility],
[searchParams],
)
const { t } = useTranslation(["dashboard", "site"])
@ -75,16 +83,14 @@ export const PagesManager: React.FC<{
text: item.text,
onClick: () => {
const newQuery: Record<string, any> = {
...router.query,
...searchParams,
visibility: item.value,
}
if (item.value === PageVisibilityEnum.All) {
delete newQuery["visibility"]
}
const search = new URLSearchParams(newQuery).toString()
router.push({
search,
})
router.push(pathname + "?" + search)
},
active: item.value === visibility,
}))

View File

@ -1,5 +1,5 @@
import { useTranslation } from "next-i18next"
import { useRouter } from "next/router"
import { useParams } from "next/navigation"
import React, { useState } from "react"
import toast from "react-hot-toast"
@ -25,8 +25,8 @@ export const PagesManagerBatchSelectActionTab: React.FC<{
}> = ({ isPost, isNotxLogContent, pages, batchSelected, setBatchSelected }) => {
const { t } = useTranslation(["dashboard", "site"])
const router = useRouter()
const subdomain = router.query.subdomain as string
const params = useParams()
const subdomain = params?.subdomain as string
const queryClient = useQueryClient()

View File

@ -1,5 +1,5 @@
import { useTranslation } from "next-i18next"
import { useRouter } from "next/router"
import { useParams, useRouter } from "next/navigation"
import { FC, useEffect, useState } from "react"
import toast from "react-hot-toast"
@ -17,8 +17,8 @@ import { useGetSite } from "~/queries/site"
import { DeleteConfirmationModal } from "./DeleteConfirmationModal"
const usePageEditLink = (page: ExpandedNote, isPost: boolean) => {
const router = useRouter()
const subdomain = router.query.subdomain as string
const params = useParams()
const subdomain = params?.subdomain as string
return `/dashboard/${subdomain}/editor?id=${page.noteId}&type=${
isPost ? "post" : "page"
@ -39,10 +39,11 @@ export const PagesManagerMenu: FC<{
const isCrossbell = !page.metadata?.content?.sources?.includes("xlog")
const router = useRouter()
const params = useParams()
const subdomain = params?.subdomain as string
const createOrUpdatePage = useCreateOrUpdatePage()
const editLink = usePageEditLink(page, isPost)
const subdomain = router.query.subdomain as string
const queryClient = useQueryClient()
const deletePage = useDeletePage()

View File

@ -1,5 +1,5 @@
import { useTranslation } from "next-i18next"
import { useRouter } from "next/router"
import { useParams, usePathname } from "next/navigation"
import React from "react"
import { useXSettingsModal } from "@crossbell/connect-kit"
@ -11,11 +11,12 @@ export const SettingsLayout: React.FC<{
title: string
children: React.ReactNode
}> = ({ title, children }) => {
const router = useRouter()
const { t } = useTranslation("dashboard")
const xSettingsModal = useXSettingsModal()
const subdomain = router.query.subdomain as string
const pathname = usePathname()
const params = useParams()
const subdomain = params?.subdomain as string
const tabItems: TabItem[] = [
{ text: "General", href: `/dashboard/${subdomain}/settings/general` },
{
@ -40,7 +41,7 @@ export const SettingsLayout: React.FC<{
text: "Export data",
href: `https://export.crossbell.io/?handle=${subdomain}`,
},
].map((item) => ({ ...item, active: router.asPath === item.href }))
].map((item) => ({ ...item, active: pathname === item.href }))
return (
<DashboardMain>

View File

@ -1,5 +1,5 @@
import { useTranslation } from "next-i18next"
import { useRouter } from "next/router"
import { useRouter } from "next/navigation"
import { Logo } from "~/components/common/Logo"
import {

View File

@ -1,4 +1,4 @@
import { useRouter } from "next/router"
import { useRouter } from "next/navigation"
import React, { useEffect } from "react"
import { useAccountState } from "@crossbell/connect-kit"

View File

@ -1,16 +1,17 @@
import pinyin from "pinyin"
// import pinyin from "pinyin"
// TODO
export const getDefaultSlug = (title: string, id?: string) => {
let generated =
pinyin(title as string, {
style: pinyin.STYLE_NORMAL,
compact: true,
})?.[0]
?.map((word) => word.trim())
?.filter((word) => word)
?.join("-")
?.replace(/\s+/g, "-") ||
id?.replace(`local-`, "") ||
// pinyin(title as string, {
// style: pinyin.STYLE_NORMAL,
// compact: true,
// })?.[0]
// ?.map((word) => word.trim())
// ?.filter((word) => word)
// ?.join("-")
// ?.replace(/\s+/g, "-") ||
// id?.replace(`local-`, "") ||
""
generated = generated.replace(/[^a-zA-Z0-9\s-_]/g, "")

View File

@ -224,8 +224,8 @@ export default function SiteSettingsGeneralPage() {
help={
<p>
<Trans i18nKey="Integrate Umami Cloud Analytics" ns="dashboard">
Integrate Umami Cloud Analytics into your site. You can follow the
instructions{" "}
Integrate Umami Cloud Analytics into your site. You can follow
the instructions{" "}
<UniLink
className="underline"
href="https://umami.is/docs/collect-data"

View File

@ -371,46 +371,45 @@ export function useGetSummary(input: { cid?: string; lang?: string }) {
})
}
// TODO
// export function useGetMirrorXyz(input: { address?: string }) {
// return useQuery(["getMirror", input.address], async () => {
// const { getDefaultSlug } = await import("~/lib/default-slug")
export function useGetMirrorXyz(input: { address?: string }) {
return useQuery(["getMirror", input.address], async () => {
const { getDefaultSlug } = await import("~/lib/default-slug")
// if (!input.address) {
// return null
// }
// const response = await (
// await fetch(
// "/api/import/mirror.xyz?" +
// new URLSearchParams({
// ...input,
// } as any),
// )
// ).json()
if (!input.address) {
return null
}
const response = await (
await fetch(
"/api/import/mirror.xyz?" +
new URLSearchParams({
...input,
} as any),
)
).json()
// return response?.data?.projectFeed?.posts?.map((post: any) => {
// return {
// title: post.title,
// date_published: new Date(
// post.publishedAtTimestamp * 1000,
// ).toISOString(),
// slug: getDefaultSlug(post.title, post.digest),
// tags: ["Mirror.xyz"],
// content: post.body,
// external_urls: [`https://mirror.xyz/${input.address}/${post.digest}`],
// }
// }) as {
// title: string
// type: string
// size: number
// date_published: string
// slug: string
// tags: string[]
// content: string
// external_urls: string[]
// }[]
// })
// }
return response?.data?.projectFeed?.posts?.map((post: any) => {
return {
title: post.title,
date_published: new Date(
post.publishedAtTimestamp * 1000,
).toISOString(),
slug: getDefaultSlug(post.title, post.digest),
tags: ["Mirror.xyz"],
content: post.body,
external_urls: [`https://mirror.xyz/${input.address}/${post.digest}`],
}
}) as {
title: string
type: string
size: number
date_published: string
slug: string
tags: string[]
content: string
external_urls: string[]
}[]
})
}
export function useCheckMirror(characterId?: number) {
return useQuery(["checkMirror", characterId], async () => {