feat: crossbell renovation - site home page

This commit is contained in:
DIYgod 2022-06-29 17:47:41 +01:00
parent 22fd6b3c4c
commit e53aa3ea02
No known key found for this signature in database
GPG Key ID: D159328F47A80DCA
9 changed files with 148 additions and 266 deletions

View File

@ -1,5 +1,6 @@
import { APP_NAME, OUR_DOMAIN } from "~/lib/env"
import { UniLink } from "../ui/UniLink"
import { IS_PROD } from "~/lib/constants"
export const SiteFooter: React.FC<{ site: { name: string } }> = ({ site }) => {
return (
@ -16,6 +17,13 @@ export const SiteFooter: React.FC<{ site: { name: string } }> = ({ site }) => {
className="hover:text-indigo-500"
>
{APP_NAME}
</UniLink>{" "}
· {" "}
<UniLink
href={`${IS_PROD ? "https" : "http"}://${OUR_DOMAIN}/dashboard`}
className="hover:text-indigo-500"
>
Dashboard
</UniLink>
</span>
</div>

View File

@ -11,6 +11,7 @@ import { DashboardIcon } from "../icons/DashboardIcon"
import { Avatar } from "../ui/Avatar"
import { Button } from "../ui/Button"
import { UniLink } from "../ui/UniLink"
import { Profile } from "unidata.js"
export type HeaderLinkType = {
icon?: React.ReactNode
@ -43,7 +44,7 @@ export const SiteHeader: React.FC<{
icon: string | null | undefined
navigation?: HeaderLinkType[]
subscribed?: boolean
viewer: Viewer | null
viewer?: Profile | null
}> = ({ siteName, description, icon, navigation, subscribed, viewer }) => {
const setSubscribeModalOpened = useStore(
(store) => store.setSubscribeModalOpened,
@ -59,20 +60,6 @@ export const SiteHeader: React.FC<{
...(navigation || []),
]
const dropdownLinks: HeaderLinkType[] = [
{
icon: <DashboardIcon />,
label: "Writer dashboard",
url: `${IS_PROD ? "https" : "http"}://${OUR_DOMAIN}/dashboard`,
},
{
label: "Sign out",
onClick() {
logout()
},
},
]
return (
<header className="border-b">
<div className="px-5 max-w-screen-md mx-auto">
@ -90,36 +77,6 @@ export const SiteHeader: React.FC<{
{description && (
<div className="text-gray-500 text-sm">{description}</div>
)}
<div className="mt-3 text-sm">
<Button
rounded="full"
size="sm"
variant={subscribed ? "secondary" : "primary"}
onClick={handleClickSubscribe}
className="space-x-1"
>
<span className="pl-1">
{subscribed ? (
<svg className="w-4 h-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m23.5 17l-5 5l-3.5-3.5l1.5-1.5l2 2l3.5-3.5l1.5 1.5M13 18H3V8l8 5l8-5v5h2V6c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h10v-2m6-12l-8 5l-8-5h16Z"
></path>
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 15v3h-3v2h3v3h2v-3h3v-2h-3v-3h-2m-5 3H3V8l8 5l8-5v5h2V6c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h11v-2m5-12l-8 5l-8-5h16Z"
></path>
</svg>
)}
</span>
<span className="pr-1">
{subscribed ? "Subscribed" : "Subscribe"}
</span>
</Button>
</div>
</div>
</div>
</div>
@ -129,41 +86,6 @@ export const SiteHeader: React.FC<{
return <HeaderLink link={link} key={`${link.label}${i}`} />
})}
</div>
{viewer ? (
<div className="relative group">
<button
type="button"
className="inline-flex space-x-2 items-center h-10"
>
<Avatar
images={[getUserContentsUrl(viewer.avatar)]}
name={viewer.name}
size={24}
/>
<span>{viewer.name}</span>
</button>
<div className="absolute hidden right-0 pt-1 group-hover:block">
<div className="bg-white rounded-lg ring-1 ring-zinc-100 min-w-[140px] shadow-md py-2">
{dropdownLinks.map((link, i) => {
return (
<UniLink
key={i}
href={link.url}
onClick={link.onClick}
className="px-4 h-8 flex items-center w-full whitespace-nowrap hover:bg-zinc-100"
>
{link.label}
</UniLink>
)
})}
</div>
</div>
</div>
) : (
<button type="button" onClick={() => setLoginModalOpened(true)}>
Sign In
</button>
)}
</div>
</div>
</header>

View File

@ -2,9 +2,10 @@ import Link from "next/link"
import { formatDate } from "~/lib/date"
import { Paginated, type PostOnSiteHome } from "~/lib/types"
import { EmptyState } from "../ui/EmptyState"
import { Notes } from "unidata.js"
export const SiteHome: React.FC<{
posts?: Paginated<PostOnSiteHome>
posts?: Notes
}> = ({ posts }) => {
if (!posts) return null
@ -13,14 +14,14 @@ export const SiteHome: React.FC<{
{posts.total === 0 && <EmptyState />}
{posts.total > 0 && (
<div className="space-y-8">
{posts.nodes.map((post) => {
const excerpt = post.excerpt || post.autoExcerpt
{posts.list.map((post) => {
const excerpt = post.summary?.content
return (
<Link key={post.id} href={`/${post.slug}`}>
<Link key={post.id} href={`/${post.id}`}>
<a className="block hover:bg-zinc-100 transition-colors p-5 -mx-5 -my-5 md:rounded-xl">
<h3 className="text-2xl font-bold">{post.title}</h3>
<div className="text-sm text-zinc-400 mt-1">
{formatDate(post.publishedAt)}
{formatDate(post.date_published)}
</div>
<div className="mt-3 text-zinc-500">
{excerpt}

View File

@ -8,16 +8,11 @@ import { SiteFooter } from "./SiteFooter"
import { SiteHeader } from "./SiteHeader"
import { useRouter } from "next/router"
import { useStore } from "~/lib/store"
import { Profile } from "unidata.js"
export type SiteLayoutProps = {
site: {
id: string
name: string
description?: string | null
icon?: string | null
navigation?: SiteNavigationItem[] | null
}
viewer: Viewer | null
site: Profile
viewer?: Profile | null
subscription?: { email?: boolean } | null
children: React.ReactNode
title?: string | null
@ -47,29 +42,22 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
<>
<SEOHead
title={title || ""}
siteName={site.name}
description={ogDescription ?? site.description}
image={getUserContentsUrl(site.icon)}
siteName={site?.name || ""}
description={ogDescription ?? site?.bio}
image={getUserContentsUrl(site?.avatars?.[0])}
/>
<SiteHeader
navigation={site!.navigation || []}
siteName={site!.name}
description={site?.description}
icon={site!.icon}
navigation={site?.navigation || []}
siteName={site?.name}
description={site?.bio}
icon={site?.avatars?.[0]}
subscribed={!!subscription}
viewer={viewer}
/>
<div className={clsx(`max-w-screen-md mx-auto px-5 pb-12`, `pt-12`)}>
{children}
</div>
<SiteFooter site={{ name: site!.name }} />
{site.id && (
<SubscribeModal
siteId={site.id}
subscription={subscription}
isLoggedIn={!!viewer}
/>
)}
<SiteFooter site={{ name: site?.name || "" }} />
</>
)
}

View File

@ -1,8 +1,5 @@
import Unidata from "unidata.js"
let unidata: Unidata | null = null;
if (typeof window !== "undefined") {
unidata = new Unidata()
}
let unidata = new Unidata()
export default unidata

View File

@ -66,35 +66,28 @@ export async function createOrUpdatePage(
isPost?: boolean
},
) {
if (unidata) {
return await unidata.notes.set({
source: 'Crossbell Note',
identity: input.siteId,
platform: 'Crossbell',
action: input.pageId ? 'update' : 'add',
}, {
...(input.pageId && { id: input.pageId }),
...(input.title && { title: input.title }),
...(input.content && {
body: {
content: input.content,
mime_type: 'text/markdown',
}}),
...(input.publishedAt && { date_published: input.published ? input.publishedAt : new Date('9999-01-01').toISOString() }),
...(input.excerpt && {
summary: {
content: input.excerpt,
mime_type: 'text/markdown',
}
}),
tags: [input.isPost ? "post" : "page"],
})
} else {
return {
code: 1,
message: 'Unidata is not found',
}
}
return await unidata.notes.set({
source: 'Crossbell Note',
identity: input.siteId,
platform: 'Crossbell',
action: input.pageId ? 'update' : 'add',
}, {
...(input.pageId && { id: input.pageId }),
...(input.title && { title: input.title }),
...(input.content && {
body: {
content: input.content,
mime_type: 'text/markdown',
}}),
...(input.publishedAt && { date_published: input.published ? input.publishedAt : new Date('9999-01-01').toISOString() }),
...(input.excerpt && {
summary: {
content: input.excerpt,
mime_type: 'text/markdown',
}
}),
tags: [input.isPost ? "post" : "page"],
})
}
export async function scheduleEmailForPost(
@ -147,63 +140,58 @@ export async function getPagesBySite(
const visibility = input.visibility || PageVisibilityEnum.All
let pages: Notes | null = null
if (unidata) {
pages = await unidata.notes.get({
source: 'Crossbell Note',
identity: input.site,
platform: 'Crossbell',
limit: input.take || 100,
});
let pages = await unidata.notes.get({
source: 'Crossbell Note',
identity: input.site,
platform: 'Crossbell',
limit: input.take || 1000,
});
if (pages?.list) {
switch (visibility) {
case PageVisibilityEnum.Published:
pages.list = pages.list.filter(page => +new Date(page.date_published) <= +new Date())
break
case PageVisibilityEnum.Draft:
pages.list = pages.list.filter(page => page.date_published === new Date('9999-01-01').toISOString())
break
case PageVisibilityEnum.Scheduled:
pages.list = pages.list.filter(page => +new Date(page.date_published) > +new Date() && page.date_published !== new Date('9999-01-01').toISOString())
break
}
pages.list = pages.list.filter(page => page.tags?.includes(input.type))
pages.total = pages.list.length
pages.list = await Promise.all(pages?.list.map(async (page) => {
if (page.body?.content && page.body?.mime_type === 'text/markdown' && input.render) {
const rendered = await renderPageContent(page.body.content)
page.body = {
content: rendered.contentHTML,
if (pages?.list) {
switch (visibility) {
case PageVisibilityEnum.Published:
pages.list = pages.list.filter(page => +new Date(page.date_published) <= +new Date())
break
case PageVisibilityEnum.Draft:
pages.list = pages.list.filter(page => page.date_published === new Date('9999-01-01').toISOString())
break
case PageVisibilityEnum.Scheduled:
pages.list = pages.list.filter(page => +new Date(page.date_published) > +new Date() && page.date_published !== new Date('9999-01-01').toISOString())
break
}
pages.list = pages.list.filter(page => page.tags?.includes(input.type))
pages.total = pages.list.length
pages.list = await Promise.all(pages?.list.map(async (page) => {
if (page.body?.content && page.body?.mime_type === 'text/markdown' && input.render) {
const rendered = await renderPageContent(page.body.content)
page.body = {
content: rendered.contentHTML,
mime_type: 'text/html'
}
if (!page.summary) {
page.summary = {
content: rendered.excerpt,
mime_type: 'text/html'
}
if (!page.summary) {
page.summary = {
content: rendered.excerpt,
mime_type: 'text/html'
}
}
}
return page
}))
}
return pages
}
return page
}))
}
return pages
}
export async function deletePage({ site, id }: { site: string, id: string }) {
if (unidata) {
await unidata.notes.set({
source: 'Crossbell Note',
identity: site,
platform: 'Crossbell',
action: 'remove',
}, {
id,
})
}
return await unidata.notes.set({
source: 'Crossbell Note',
identity: site,
platform: 'Crossbell',
action: 'remove',
}, {
id,
})
}
export async function getPage<TRender extends boolean = false>(

View File

@ -40,39 +40,31 @@ export const checkSubdomain = async ({
}
export const getUserSites = async (address: string) => {
if (unidata) {
const profiles = await unidata.profiles.get({
source: 'Crossbell Profile',
identity: address,
platform: 'Ethereum',
});
const profiles = await unidata.profiles.get({
source: 'Crossbell Profile',
identity: address,
platform: 'Ethereum',
});
const sites = profiles.list?.sort((a, b) => +new Date(b.date_updated || 0) - +new Date(a.date_updated || 0))
const sites = profiles.list?.sort((a, b) => +new Date(b.date_updated || 0) - +new Date(a.date_updated || 0))
if (!sites || !sites.length) return null
if (!sites || !sites.length) return null
return sites
} else {
return null
}
return sites
}
export const getSite = async (input: string) => {
if (unidata) {
const profiles = await unidata.profiles.get({
source: 'Crossbell Profile',
identity: input,
platform: 'Crossbell',
});
const profiles = await unidata.profiles.get({
source: 'Crossbell Profile',
identity: input,
platform: 'Crossbell',
});
const site = profiles.list?.sort((a, b) => +new Date(b.date_updated || 0) - +new Date(a.date_updated || 0))?.[0]
const site = profiles.list?.sort((a, b) => +new Date(b.date_updated || 0) - +new Date(a.date_updated || 0))?.[0]
if (!site) return null
if (!site) return null
return site
} else {
return null
}
return site
}
export const getSubscription = async (data: {
@ -103,24 +95,17 @@ export async function updateSite(
navigation?: SiteNavigationItem[] // TODO
},
) {
if (unidata) {
return await unidata.profiles.set({
source: 'Crossbell Profile',
identity: payload.site,
platform: 'Crossbell',
action: 'update',
}, {
...(payload.name && { name: payload.name }),
...(payload.description && { bio: payload.description }),
...(payload.icon && { avatars: [payload.icon] }),
...(payload.subdomain && { username: payload.subdomain }),
})
} else {
return {
code: 1,
message: 'Unidata is not found',
}
}
return await unidata.profiles.set({
source: 'Crossbell Profile',
identity: payload.site,
platform: 'Crossbell',
action: 'update',
}, {
...(payload.name && { name: payload.name }),
...(payload.description && { bio: payload.description }),
...(payload.icon && { avatars: [payload.icon] }),
...(payload.subdomain && { username: payload.subdomain }),
})
}
export async function createSite(

View File

@ -1,35 +1,39 @@
import { GetServerSideProps } from "next"
import { SiteHome } from "~/components/site/SiteHome"
import { SiteLayout } from "~/components/site/SiteLayout"
import { getAuthUser } from "~/lib/auth.server"
import { trpc } from "~/lib/trpc"
import { createSSGHelpers } from "@trpc/react/ssg"
import { appRouter } from "~/router"
import { getTRPCContext } from "~/lib/trpc.server"
import { Viewer } from "~/lib/types"
import { getViewer } from "~/lib/viewer"
import { useAccount } from 'wagmi'
import { useEffect, useState } from 'react'
import { getUserSites, getSite } from "~/models/site.model"
import { getPagesBySite } from "~/models/page.model"
import { Profile, Notes } from "unidata.js"
import { PageVisibilityEnum } from "~/lib/types"
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const user = await getAuthUser(ctx.req)
const domainOrSubdomain = ctx.params!.site as string
const viewer = getViewer(user)
const trpcContext = await getTRPCContext(ctx)
const ssg = createSSGHelpers({ router: appRouter, ctx: trpcContext })
await Promise.all([
ssg.fetchQuery("site", { site: domainOrSubdomain }),
ssg.fetchQuery("site.pages", {
const [site, posts] = await Promise.all([
getSite(domainOrSubdomain),
getPagesBySite({
site: domainOrSubdomain,
take: 1000,
includeExcerpt: true,
type: "post",
visibility: PageVisibilityEnum.Published,
}),
ssg.fetchQuery("site.subscription", { site: domainOrSubdomain }),
])
return {
props: {
viewer,
site,
posts,
domainOrSubdomain,
trpcState: ssg.dehydrate(),
},
@ -37,27 +41,15 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
}
function SiteIndexPage({
viewer,
domainOrSubdomain,
site,
posts,
}: {
viewer: Viewer | null
site: Profile,
posts: Notes,
domainOrSubdomain: string
}) {
const { data: site } = trpc.useQuery(
["site", { site: domainOrSubdomain }],
{},
)
const { data: subscription } = trpc.useQuery([
"site.subscription",
{ site: domainOrSubdomain },
])
const { data: posts } = trpc.useQuery([
"site.pages",
{ site: domainOrSubdomain, take: 1000, includeExcerpt: true },
])
return (
<SiteLayout site={site!} viewer={viewer} subscription={subscription}>
<SiteLayout site={site!}>
<SiteHome posts={posts} />
</SiteLayout>
)

View File

@ -54,23 +54,24 @@ export const siteRouter = createRouter()
includeExcerpt: z.boolean().optional(),
}),
output: z.object({
nodes: z.array(
list: z.array(
z.object({
id: z.string(),
title: z.string(),
contentHTML: z.string().optional(),
publishedAt: z.date().transform((v) => v.toISOString()),
published: z.boolean(),
slug: z.string(),
excerpt: z.string().nullable(),
autoExcerpt: z.string().optional(),
body: z.object({
content: z.string(),
}),
date_published: z.date().transform((v) => v.toISOString()),
summary: z.object({
content: z.string(),
}),
}),
),
total: z.number(),
hasMore: z.boolean(),
}),
async resolve({ input, ctx }) {
const result = await getPagesBySite(ctx.gate, input)
const result = await getPagesBySite(input)
return result
},
})