Skip to content

Commit 11c1706

Browse files
committed
feat(moment): added a Moments page to showcase public moments
1 parent bf30e1a commit 11c1706

File tree

5 files changed

+269
-8
lines changed

5 files changed

+269
-8
lines changed

app/moment/page.tsx

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { Container } from '~/components/ui/container'
2+
import { PageHeader } from '~/components/ui/page-header'
3+
import { Image, Zoom } from '~/components/ui/image'
4+
import { GritBackground } from '~/components/ui/grit-background'
5+
import { SITE_METADATA } from '~/data/site-metadata'
6+
import { AUTHOR_INFO } from '~/data/author-info'
7+
import { formatDate } from '~/utils/date'
8+
import { clsx } from 'clsx'
9+
10+
type MomentResponse = { moment: Moment[] } | { memos: Moment[] }
11+
12+
interface Moment {
13+
name: string
14+
state: string
15+
creator: string
16+
createTime: string
17+
updateTime: string
18+
displayTime: string
19+
content: string
20+
nodes?: any[]
21+
visibility: 'PUBLIC' | 'PRIVATE' | string
22+
tags?: string[]
23+
pinned?: boolean
24+
resources?: Resource[]
25+
relations?: any[]
26+
reactions?: any[]
27+
property?: Record<string, unknown>
28+
snippet?: string
29+
}
30+
31+
interface Resource {
32+
name: string
33+
createTime: string
34+
filename: string
35+
content: string
36+
externalLink: string
37+
type: string
38+
size: string
39+
memo: string
40+
}
41+
42+
async function fetchPublicMoment(): Promise<Moment[]> {
43+
const url = `${SITE_METADATA.momentApi}/api/v1/memos?visibility=PUBLIC`
44+
if (!url) return []
45+
const res = await fetch(url, { next: { revalidate: 60 } })
46+
if (!res.ok) return []
47+
const data = (await res.json()) as MomentResponse
48+
// 兼容两种字段:memos / moment
49+
// @ts-ignore
50+
return (data.memos as Moment[]) || (data.moment as Moment[]) || []
51+
}
52+
53+
function getResourceUrl(resource: Resource): string | null {
54+
if (resource.externalLink) return resource.externalLink
55+
// Build raw URL for self-hosted usememos
56+
// resource.name like: "resources/7JoNrgZzCgvtWeLVyjMpT6"
57+
const id = resource.name?.split('/')?.[1]
58+
if (!id) return null
59+
return `${SITE_METADATA.momentApi}/file/${resource.name}/${resource.filename}`
60+
}
61+
62+
export default async function MomentPage() {
63+
const moments = await fetchPublicMoment()
64+
65+
if (!SITE_METADATA.momentApi) {
66+
return (
67+
<Container className="py-6">
68+
<PageHeader
69+
title="Moments"
70+
description="Data source not configured"
71+
className="border-b border-gray-200 pb-6 dark:border-gray-700"
72+
/>
73+
<div className="mx-auto mt-8 max-w-2xl">
74+
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-12 text-center dark:border-gray-700 dark:bg-gray-800/50">
75+
<div className="text-gray-500 dark:text-gray-400">
76+
<div className="mb-2 text-lg font-medium">Data source not configured</div>
77+
<div className="text-sm">
78+
Please configure `NEXT_PUBLIC_MOMENT_API` in the environment variable
79+
</div>
80+
</div>
81+
</div>
82+
</div>
83+
</Container>
84+
)
85+
}
86+
87+
return (
88+
<Container className="py-6">
89+
<PageHeader
90+
title="Moments"
91+
description="Record every bit of life and share your daily thoughts and insights"
92+
className="border-b border-gray-200 pb-6 dark:border-gray-700"
93+
/>
94+
<div className="mx-auto mt-8 max-w-2xl space-y-8">
95+
{moments.map((momentItem) => {
96+
const images = (momentItem.resources || [])
97+
.filter((r) => r.type.startsWith('image/'))
98+
.map((r) => ({ url: getResourceUrl(r), filename: r.filename }))
99+
.filter((r) => !!r.url) as { url: string; filename: string }[]
100+
101+
return (
102+
<article key={momentItem.name} className="group">
103+
<div
104+
className={clsx([
105+
'relative overflow-hidden rounded-2xl',
106+
'bg-white shadow-sm transition-all duration-300',
107+
'dark:bg-white/5 dark:shadow-none',
108+
'border border-gray-100 dark:border-gray-800',
109+
'hover:shadow-lg hover:shadow-gray-900/5',
110+
'dark:hover:shadow-black/20',
111+
])}
112+
>
113+
<GritBackground className="absolute inset-0 opacity-30 dark:opacity-100" />
114+
<div className="relative p-6">
115+
<div className="mb-4 flex items-center gap-3">
116+
<div className="relative">
117+
<Image
118+
src={SITE_METADATA.siteLogo}
119+
alt={AUTHOR_INFO.name}
120+
width={40}
121+
height={40}
122+
className="h-10 w-10 rounded-full ring-2 ring-white dark:ring-gray-800"
123+
/>
124+
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full bg-green-500 ring-2 ring-white dark:ring-gray-900"></div>
125+
</div>
126+
<div className="leading-tight">
127+
<div className="font-semibold text-gray-900 dark:text-gray-100">
128+
{AUTHOR_INFO.name}
129+
</div>
130+
<div className="text-sm text-gray-500 dark:text-gray-400">
131+
{formatDate(momentItem.displayTime || momentItem.createTime)}
132+
</div>
133+
</div>
134+
</div>
135+
<div className="prose prose-sm prose-gray max-w-none dark:prose-invert">
136+
{renderContent(momentItem)}
137+
</div>
138+
{images.length > 0 && (
139+
<div
140+
className={clsx([
141+
'mt-4 grid gap-2',
142+
images.length === 1 ? 'grid-cols-1' : 'grid-cols-2 sm:grid-cols-3',
143+
])}
144+
>
145+
{images.map((img) => (
146+
<Zoom key={img.url!}>
147+
<Image
148+
src={img.url}
149+
alt={img.filename}
150+
width={600}
151+
height={600}
152+
className={clsx([
153+
'overflow-hidden rounded-lg transition-all duration-200',
154+
'hover:scale-[1.02] hover:shadow-lg',
155+
images.length === 1 ? 'aspect-video' : 'aspect-square',
156+
])}
157+
/>
158+
</Zoom>
159+
))}
160+
</div>
161+
)}
162+
</div>
163+
</div>
164+
</article>
165+
)
166+
})}
167+
{moments.length === 0 && (
168+
<div className="mx-auto max-w-2xl">
169+
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-12 text-center dark:border-gray-700 dark:bg-gray-800/50">
170+
<div className="text-gray-500 dark:text-gray-400">
171+
<div className="mb-2 text-lg font-medium">Empty</div>
172+
<div className="text-sm">No moments shared yet</div>
173+
</div>
174+
</div>
175+
</div>
176+
)}
177+
</div>
178+
</Container>
179+
)
180+
}
181+
182+
function renderContent(moment: Moment) {
183+
if (!moment.nodes || moment.nodes.length === 0) {
184+
return moment.content
185+
}
186+
return moment.nodes.map((node: any, idx: number) => {
187+
if (node.type === 'PARAGRAPH') {
188+
const text = (node.paragraphNode?.children || [])
189+
.map((c: any) => {
190+
if (c.type === 'TEXT') return c.textNode?.content
191+
if (c.type === 'AUTO_LINK') {
192+
const url = c.autoLinkNode?.url
193+
return url
194+
? `<a class="text-blue-600 hover:underline dark:text-blue-400" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
195+
: ''
196+
}
197+
return ''
198+
})
199+
.join('')
200+
if (!text) return null
201+
return <p key={idx} className="mb-0 mt-0" dangerouslySetInnerHTML={{ __html: text }} />
202+
}
203+
if (node.type === 'CODE_BLOCK') {
204+
const lang = node.codeBlockNode?.language || ''
205+
const code = node.codeBlockNode?.content || ''
206+
return (
207+
<pre
208+
key={idx}
209+
className="my-4 overflow-x-auto rounded-lg bg-gray-100 p-4 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100"
210+
>
211+
<code className={`language-${lang}`}>{code}</code>
212+
</pre>
213+
)
214+
}
215+
if (node.type === 'LIST') {
216+
const ordered = node.listNode?.kind === 'ORDERED'
217+
const items: any[] = node.listNode?.children || []
218+
const elements = items
219+
.map((it: any, i: number) => {
220+
if (it.type === 'LINE_BREAK') return null
221+
const container = it.orderedListItemNode || it.unorderedListItemNode
222+
if (!container) return null
223+
const content = (container.children || [])
224+
.map((c: any) => {
225+
if (c.type === 'TEXT') return c.textNode?.content
226+
if (c.type === 'AUTO_LINK') {
227+
const url = c.autoLinkNode?.url
228+
return url
229+
? `<a class="text-blue-600 hover:underline dark:text-blue-400" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
230+
: ''
231+
}
232+
return ''
233+
})
234+
.join('')
235+
return <li key={i} className="mb-1" dangerouslySetInnerHTML={{ __html: content }} />
236+
})
237+
.filter(Boolean)
238+
return ordered ? (
239+
<ol key={idx} className="my-4 list-decimal space-y-1 pl-6">
240+
{elements}
241+
</ol>
242+
) : (
243+
<ul key={idx} className="my-4 list-disc space-y-1 pl-6">
244+
{elements}
245+
</ul>
246+
)
247+
}
248+
if (node.type === 'LINE_BREAK') {
249+
return idx !== 0 && moment.nodes?.[idx - 1].type === 'LINE_BREAK' ? <br key={idx} /> : <></>
250+
}
251+
return null
252+
})
253+
}

data/navigation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { SITE_METADATA } from './site-metadata'
33
export const HEADER_NAV_LINKS = [
44
{ href: '/blog', title: 'Blog', emoji: 'writing-hand' },
55
{ href: '/snippets', title: 'Snippets', emoji: 'dna' },
6+
...(SITE_METADATA.momentApi
7+
? [{ href: '/moment', title: 'Moment', emoji: 'speech-balloon' }]
8+
: []),
69
{ href: '/projects', title: 'Projects', emoji: 'man-technologist' },
710
{ href: '/about', title: 'About', emoji: 'smiling-face-with-sunglasses' },
811
]

data/site-metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const SITE_METADATA = {
3535
lang: 'en',
3636
},
3737
},
38+
momentApi: 'https://mengke.zeabur.app',
3839
search: {
3940
kbarConfigs: {
4041
// path to load documents to search

json/tag-data.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
"fe": 13,
55
"javascript": 6,
66
"flat": 1,
7-
"vue": 5,
8-
"vue3": 2,
97
"react": 3,
108
"react-router-dom": 1,
9+
"vue": 5,
10+
"vue3": 2,
1111
"deno": 1,
1212
"nextjs": 2,
1313
"database": 1,
@@ -35,22 +35,22 @@
3535
"life": 4,
3636
"year-end": 2,
3737
"summary": 2,
38-
"diary": 1,
39-
"leap-day": 1,
40-
"websites": 1,
41-
"collect": 1,
4238
"optimization": 1,
4339
"webworker": 1,
4440
"travel": 1,
4541
"japan": 1,
4642
"blog": 1,
4743
"code-life": 1,
44+
"diary": 1,
45+
"leap-day": 1,
46+
"websites": 1,
47+
"collect": 1,
4848
"ai": 4,
49-
"mcp": 3,
50-
"llm": 4,
5149
"deepseek": 1,
50+
"llm": 4,
5251
"ollama": 1,
5352
"dify": 1,
53+
"mcp": 3,
5454
"harmonyos": 1,
5555
"app": 1,
5656
"typescript": 3,

next.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ module.exports = () => {
8282
protocol: 'https',
8383
hostname: 'm.media-amazon.com', // IMDB movie posters
8484
},
85+
{
86+
protocol: 'https',
87+
hostname: 'mengke.zeabur.app', // usemoment resources
88+
},
8589
],
8690
unoptimized,
8791
},

0 commit comments

Comments
 (0)