mirror of
https://github.com/lbr77/blog-astro.git
synced 2026-04-08 16:11:56 +00:00
feat: refactor Notion API integration and update Docker configuration
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
node_modules/
|
.env
|
||||||
dist/
|
.git
|
||||||
.astro/
|
.gitignore
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|||||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
NOTION_API_BASE_URL=http://127.0.0.1:3000
|
||||||
|
PUBLIC_NOTION_API_BASE_URL=http://127.0.0.1:3000
|
||||||
25
Dockerfile
25
Dockerfile
@@ -1,14 +1,27 @@
|
|||||||
FROM oven/bun:latest AS build
|
FROM oven/bun:1 AS build
|
||||||
|
|
||||||
ADD * /app/
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN bun install && \
|
COPY package.json bun.lock ./
|
||||||
bun run astro build
|
COPY tsconfig.json components.json astro.config.ts ./
|
||||||
|
COPY patches ./patches
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENV ASTRO_ADAPTER=node
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
FROM node:22-alpine
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
COPY --from=build /app/package.json ./package.json
|
COPY --from=build /app/package.json ./package.json
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "dist/server/entry.mjs"]
|
CMD ["node", "dist/server/entry.mjs"]
|
||||||
@@ -17,8 +17,11 @@ import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-s
|
|||||||
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
|
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
|
||||||
|
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import vercel from "@astrojs/vercel"
|
import vercel from '@astrojs/vercel'
|
||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node'
|
||||||
|
|
||||||
|
const adapterName = process.env.ASTRO_ADAPTER ?? 'vercel'
|
||||||
|
const adapter = adapterName === 'node' ? node({ mode: 'standalone' }) : vercel()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://nvme0n1p.dev',
|
site: 'https://nvme0n1p.dev',
|
||||||
@@ -44,8 +47,7 @@ export default defineConfig({
|
|||||||
codeFontSize: '0.75rem',
|
codeFontSize: '0.75rem',
|
||||||
borderColor: 'var(--border)',
|
borderColor: 'var(--border)',
|
||||||
codeFontFamily: 'var(--font-mono)',
|
codeFontFamily: 'var(--font-mono)',
|
||||||
codeBackground:
|
codeBackground: 'color-mix(in oklab, var(--muted) 25%, transparent)',
|
||||||
'color-mix(in oklab, var(--muted) 25%, transparent)',
|
|
||||||
frames: {
|
frames: {
|
||||||
editorActiveTabForeground: 'var(--muted-foreground)',
|
editorActiveTabForeground: 'var(--muted-foreground)',
|
||||||
editorActiveTabBackground:
|
editorActiveTabBackground:
|
||||||
@@ -112,5 +114,5 @@ export default defineConfig({
|
|||||||
remarkPlugins: [remarkMath, remarkEmoji],
|
remarkPlugins: [remarkMath, remarkEmoji],
|
||||||
},
|
},
|
||||||
|
|
||||||
adapter: vercel(),
|
adapter,
|
||||||
})
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { SITE } from '@/consts'
|
import { SITE } from '@/consts'
|
||||||
import { isSubpost } from '@/lib/data-utils'
|
import { isSubpost } from '@/lib/data-utils'
|
||||||
|
import { buildNotionApiUrl } from '@/lib/notion/api'
|
||||||
import type { CollectionEntry } from 'astro:content'
|
import type { CollectionEntry } from 'astro:content'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,7 +18,7 @@ const author =
|
|||||||
? post.data.authors.join(', ')
|
? post.data.authors.join(', ')
|
||||||
: SITE.author
|
: SITE.author
|
||||||
const ogImage = {
|
const ogImage = {
|
||||||
url: new URL(`/api/og/${encodeURIComponent(post.id)}`, siteUrl).toString(),
|
url: buildNotionApiUrl(`/og/${encodeURIComponent(post.id)}.png`),
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getCollection, render, type CollectionEntry } from 'astro:content'
|
import { getCollection, render, type CollectionEntry } from 'astro:content'
|
||||||
import { readingTime, calculateWordCountFromHtml } from '@/lib/utils'
|
import { readingTime, calculateWordCountFromHtml } from '@/lib/utils'
|
||||||
import type { Block, RichText } from './interfaces'
|
import type { Block, RichText } from './interfaces'
|
||||||
|
import { NotionApiError, fetchNotionApiJson } from './notion/api'
|
||||||
import {
|
import {
|
||||||
buildHeadingId,
|
buildHeadingId,
|
||||||
buildText,
|
buildText,
|
||||||
@@ -56,6 +57,11 @@ export type RemotePostPayload = {
|
|||||||
rootId: string
|
rootId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PostsPayload = {
|
||||||
|
posts?: NotionPost[]
|
||||||
|
length?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface LinkEntry {
|
export interface LinkEntry {
|
||||||
id: string
|
id: string
|
||||||
picLink?: string
|
picLink?: string
|
||||||
@@ -95,21 +101,35 @@ export function normalizePost(post: NotionPost): CollectionEntry<'blog'> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllPosts(page?: Number, size?: Number): Promise<CollectionEntry<'blog'>[]> {
|
export async function fetchPostsPage(
|
||||||
|
page = 1,
|
||||||
try {
|
size = 1000,
|
||||||
const res = await fetch(`https://notion-api.nvme0n1p.dev/v2/posts?page=${page ?? 1}&length=${size ?? 1000}`)
|
): Promise<{
|
||||||
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`)
|
posts: CollectionEntry<'blog'>[]
|
||||||
const payload = (await res.json()) as { posts?: NotionPost[], length?: number }
|
totalPages: number
|
||||||
const posts = (payload.posts ?? []).filter(
|
}> {
|
||||||
(post) => post.Published ?? true,
|
const payload = await fetchNotionApiJson<PostsPayload>(
|
||||||
|
`/v2/posts?page=${page}&length=${size}`,
|
||||||
)
|
)
|
||||||
const length = payload.length;
|
const posts = (payload.posts ?? []).filter((post) => post.Published ?? true)
|
||||||
const normalized = posts.map(normalizePost)
|
const normalized = posts
|
||||||
|
.map(normalizePost)
|
||||||
return normalized
|
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||||
.filter((post) => !isSubpost(post.id)) as unknown as CollectionEntry<'blog'>[]
|
.filter((post) => !isSubpost(post.id)) as CollectionEntry<'blog'>[]
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts: normalized,
|
||||||
|
totalPages: payload.length ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllPosts(
|
||||||
|
page?: number,
|
||||||
|
size?: number,
|
||||||
|
): Promise<CollectionEntry<'blog'>[]> {
|
||||||
|
try {
|
||||||
|
const { posts } = await fetchPostsPage(page ?? 1, size ?? 1000)
|
||||||
|
return posts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('getAllPosts remote fetch failed, using fallback:', error)
|
console.error('getAllPosts remote fetch failed, using fallback:', error)
|
||||||
return []
|
return []
|
||||||
@@ -122,7 +142,6 @@ export async function getAllPostsAndSubposts(): Promise<
|
|||||||
return getAllPosts()
|
return getAllPosts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getAllTags(): Promise<Map<string, number>> {
|
export async function getAllTags(): Promise<Map<string, number>> {
|
||||||
const posts = await getAllPosts()
|
const posts = await getAllPosts()
|
||||||
return posts.reduce((acc, post) => {
|
return posts.reduce((acc, post) => {
|
||||||
@@ -238,28 +257,32 @@ export function isSubpost(postId: string): boolean {
|
|||||||
|
|
||||||
export async function fetchRemotePost(
|
export async function fetchRemotePost(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<NotionPost | null> {
|
): Promise<{ post: NotionPost } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://notion-api.nvme0n1p.dev/v2/posts/${slug}`)
|
const data = await fetchNotionApiJson<Partial<{ post: NotionPost }>>(
|
||||||
if (!res.ok) throw new Error(`Failed to fetch post: ${res.status}`)
|
`/v2/posts/${encodeURI(slug)}?info=true`,
|
||||||
const data = (await res.json()) as NotionPost
|
)
|
||||||
|
if (!data.post) return null
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`fetchRemotePost error for slug "${slug}":`, error)
|
if (error instanceof NotionApiError && error.status === 404) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
console.error(`fetchRemotePost error for slug "${slug}":`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRemotePostContent(
|
export async function fetchRemotePostContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<RemotePostPayload | null> {
|
): Promise<RemotePostPayload | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://notion-api.nvme0n1p.dev/v2/posts/${encodeURI(slug)}`)
|
const data = await fetchNotionApiJson<
|
||||||
if (!res.ok) throw new Error(`Failed to fetch post content: ${res.status}`)
|
Partial<{
|
||||||
const data = (await res.json()) as Partial<{
|
|
||||||
post: NotionPost
|
post: NotionPost
|
||||||
blockMap: NotionBlockMap
|
blockMap: NotionBlockMap
|
||||||
}>
|
}>
|
||||||
|
>(`/v2/posts/${encodeURI(slug)}`)
|
||||||
if (!data.post || !data.blockMap) return null
|
if (!data.post || !data.blockMap) return null
|
||||||
|
|
||||||
const rootId = data.post.id
|
const rootId = data.post.id
|
||||||
@@ -271,18 +294,17 @@ export async function fetchRemotePostContent(
|
|||||||
rootId,
|
rootId,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`fetchRemotePostContent error for slug "${slug}":`, error)
|
if (error instanceof NotionApiError && error.status === 404) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
console.error(`fetchRemotePostContent error for slug "${slug}":`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFriendLinks(): Promise<LinkEntry[]> {
|
export async function getFriendLinks(): Promise<LinkEntry[]> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://notion-api.nvme0n1p.dev/v2/links")
|
const data = await fetchNotionApiJson<LinkEntry[]>('/v2/links')
|
||||||
if (!res.ok) throw new Error(`Failed to fetch links: ${res.status}`)
|
|
||||||
|
|
||||||
const data = (await res.json()) as LinkEntry[]
|
|
||||||
if (!Array.isArray(data)) throw new Error('Invalid links data format')
|
if (!Array.isArray(data)) throw new Error('Invalid links data format')
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@@ -295,7 +317,6 @@ export async function getFriendLinks(): Promise<LinkEntry[]> {
|
|||||||
export async function getParentPost(
|
export async function getParentPost(
|
||||||
subpostId: string,
|
subpostId: string,
|
||||||
): Promise<CollectionEntry<'blog'> | null> {
|
): Promise<CollectionEntry<'blog'> | null> {
|
||||||
|
|
||||||
const parentId = getParentId(subpostId)
|
const parentId = getParentId(subpostId)
|
||||||
const allPosts = await getAllPosts()
|
const allPosts = await getAllPosts()
|
||||||
return allPosts.find((post) => post.id === parentId) || null
|
return allPosts.find((post) => post.id === parentId) || null
|
||||||
@@ -635,7 +656,8 @@ function buildBlocks(
|
|||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
Id: `${targetType}-${listItems[0]?.Id ?? i}`,
|
Id: `${targetType}-${listItems[0]?.Id ?? i}`,
|
||||||
Type: targetType === 'bulleted_list' ? 'bulleted_list' : 'numbered_list',
|
Type:
|
||||||
|
targetType === 'bulleted_list' ? 'bulleted_list' : 'numbered_list',
|
||||||
HasChildren: listItems.some((item) => item.HasChildren),
|
HasChildren: listItems.some((item) => item.HasChildren),
|
||||||
ListItems: listItems,
|
ListItems: listItems,
|
||||||
})
|
})
|
||||||
@@ -975,11 +997,7 @@ export function renderRemoteBlockMap(
|
|||||||
slug: buildHeadingId(heading),
|
slug: buildHeadingId(heading),
|
||||||
text: richTextToPlainText(heading?.RichTexts ?? []),
|
text: richTextToPlainText(heading?.RichTexts ?? []),
|
||||||
depth:
|
depth:
|
||||||
block.Type === 'heading_1'
|
block.Type === 'heading_1' ? 2 : block.Type === 'heading_2' ? 3 : 4,
|
||||||
? 2
|
|
||||||
: block.Type === 'heading_2'
|
|
||||||
? 3
|
|
||||||
: 4,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
58
src/lib/notion/api.ts
Normal file
58
src/lib/notion/api.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const DEFAULT_NOTION_API_BASE_URL = 'https://notion-api.nvme0n1p.dev'
|
||||||
|
|
||||||
|
function readRuntimeEnv(key: string): string | undefined {
|
||||||
|
if (typeof process === 'undefined') return undefined
|
||||||
|
const value = process.env[key]
|
||||||
|
return value && value.trim() ? value.trim() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotionApiError extends Error {
|
||||||
|
status: number
|
||||||
|
url: string
|
||||||
|
|
||||||
|
constructor(message: string, status: number, url: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'NotionApiError'
|
||||||
|
this.status = status
|
||||||
|
this.url = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value: string): string {
|
||||||
|
return value.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotionApiServerBaseUrl(): string {
|
||||||
|
return normalizeBaseUrl(
|
||||||
|
readRuntimeEnv('NOTION_API_BASE_URL') ||
|
||||||
|
readRuntimeEnv('PUBLIC_NOTION_API_BASE_URL') ||
|
||||||
|
import.meta.env.NOTION_API_BASE_URL ||
|
||||||
|
import.meta.env.PUBLIC_NOTION_API_BASE_URL ||
|
||||||
|
DEFAULT_NOTION_API_BASE_URL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotionApiPublicBaseUrl(): string {
|
||||||
|
return normalizeBaseUrl(
|
||||||
|
readRuntimeEnv('PUBLIC_NOTION_API_BASE_URL') ||
|
||||||
|
readRuntimeEnv('NOTION_API_BASE_URL') ||
|
||||||
|
import.meta.env.PUBLIC_NOTION_API_BASE_URL ||
|
||||||
|
import.meta.env.NOTION_API_BASE_URL ||
|
||||||
|
DEFAULT_NOTION_API_BASE_URL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNotionApiUrl(path: string): string {
|
||||||
|
return new URL(path, `${getNotionApiPublicBaseUrl()}/`).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotionApiJson<T>(path: string): Promise<T> {
|
||||||
|
const url = new URL(path, `${getNotionApiServerBaseUrl()}/`).toString()
|
||||||
|
const res = await fetch(url)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new NotionApiError(`Failed to fetch ${url}: ${res.status}`, res.status, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
|
|
||||||
import type { Post } from '../interfaces'
|
import type { Post } from '../interfaces'
|
||||||
|
import { fetchNotionApiJson } from './api'
|
||||||
|
|
||||||
export async function getPostByPageId(pageId: string): Promise<Post | null> {
|
export async function getPostByPageId(pageId: string): Promise<Post | null> {
|
||||||
if (!pageId) return null
|
if (!pageId) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://notion-api.nvme0n1p.dev/v2/posts")
|
const payload = await fetchNotionApiJson<{ posts?: any[] }>('/v2/posts')
|
||||||
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`)
|
|
||||||
|
|
||||||
const payload = (await res.json()) as { posts?: any[] }
|
|
||||||
const match = (payload.posts ?? []).find((post) => post.id === pageId)
|
const match = (payload.posts ?? []).find((post) => post.id === pageId)
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro'
|
|
||||||
import type { CSSProperties } from 'react'
|
|
||||||
import { createElement } from 'react'
|
|
||||||
import { ImageResponse } from '@vercel/og'
|
|
||||||
import { SITE } from '@/consts'
|
|
||||||
import { fetchRemotePost, normalizePost } from '@/lib/data-utils'
|
|
||||||
import { formatDate } from '@/lib/utils'
|
|
||||||
|
|
||||||
const WIDTH = 1200
|
|
||||||
const HEIGHT = 630
|
|
||||||
|
|
||||||
const FONT_STACK =
|
|
||||||
'Geist, "Inter", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif'
|
|
||||||
|
|
||||||
export const config = { runtime: 'edge' }
|
|
||||||
|
|
||||||
function truncateChars(input: string, maxChars: number) {
|
|
||||||
const chars = Array.from(input)
|
|
||||||
if (chars.length <= maxChars) return input
|
|
||||||
if (maxChars <= 3) return chars.slice(0, maxChars).join('')
|
|
||||||
return chars.slice(0, maxChars - 3).join('') + '...'
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapText(input: string, maxChars: number, maxLines: number) {
|
|
||||||
const text = input.replace(/\s+/g, ' ').trim()
|
|
||||||
if (!text) return []
|
|
||||||
const chars = Array.from(text)
|
|
||||||
const lines: string[] = []
|
|
||||||
let current: string[] = []
|
|
||||||
|
|
||||||
for (const ch of chars) {
|
|
||||||
if (current.length >= maxChars) {
|
|
||||||
lines.push(current.join(''))
|
|
||||||
current = []
|
|
||||||
if (lines.length >= maxLines) break
|
|
||||||
}
|
|
||||||
current.push(ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.length < maxLines && current.length > 0) {
|
|
||||||
lines.push(current.join(''))
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxTotal = maxChars * maxLines
|
|
||||||
if (chars.length > maxTotal && lines.length > 0) {
|
|
||||||
lines[lines.length - 1] = truncateChars(
|
|
||||||
lines[lines.length - 1],
|
|
||||||
maxChars,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerStyle: CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
padding: '48px',
|
|
||||||
color: '#0f172a',
|
|
||||||
fontFamily: FONT_STACK,
|
|
||||||
backgroundImage: 'linear-gradient(130deg, #fdf2f8 0%, #e0f2fe 60%, #f0fdf4 100%)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardStyle: CSSProperties = {
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '32px',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '1040px',
|
|
||||||
padding: '64px',
|
|
||||||
borderRadius: '32px',
|
|
||||||
border: '1px solid rgba(226, 232, 240, 0.7)',
|
|
||||||
background:
|
|
||||||
'linear-gradient(120deg, rgba(255, 255, 255, 0.92), rgba(240, 253, 244, 0.95))',
|
|
||||||
boxShadow: '0 40px 120px rgba(148, 163, 184, 0.35)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridStyle: CSSProperties = {
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(rgba(14, 116, 144, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(14, 116, 144, 0.08) 1px, transparent 1px)',
|
|
||||||
backgroundSize: '40px 40px',
|
|
||||||
borderRadius: '32px',
|
|
||||||
maskImage: 'radial-gradient(circle at center, rgba(255, 255, 255, 0.9), transparent 70%)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}
|
|
||||||
|
|
||||||
const badgeStyle: CSSProperties = {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: '0.25em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'rgba(15, 23, 42, 0.75)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
}
|
|
||||||
|
|
||||||
const badgeDotStyle: CSSProperties = {
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: '999px',
|
|
||||||
background:
|
|
||||||
'radial-gradient(circle, rgba(236, 72, 153, 0.9), rgba(236, 72, 153, 0.3))',
|
|
||||||
boxShadow: '0 0 20px rgba(236, 72, 153, 0.4)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleStyle: CSSProperties = {
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
lineHeight: 1.15,
|
|
||||||
color: '#0f172a',
|
|
||||||
whiteSpace: 'pre-line',
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaRowStyle: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderTop: '1px solid rgba(148, 163, 184, 0.3)',
|
|
||||||
paddingTop: '28px',
|
|
||||||
marginTop: '8px',
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaLeftStyle: CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '6px',
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaLabelStyle: CSSProperties = {
|
|
||||||
fontSize: 18,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.2em',
|
|
||||||
color: 'rgba(15, 118, 110, 0.7)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaValueStyle: CSSProperties = {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#0f172a',
|
|
||||||
}
|
|
||||||
|
|
||||||
const signatureStyle: CSSProperties = {
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'rgba(14, 116, 144, 0.85)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapAsBlock = (value: string, maxChars: number, maxLines: number) =>
|
|
||||||
wrapText(value, maxChars, maxLines).join('\n')
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params }) => {
|
|
||||||
let slug = params.slug ?? ''
|
|
||||||
if (slug) {
|
|
||||||
try {
|
|
||||||
slug = decodeURIComponent(slug)
|
|
||||||
} catch {
|
|
||||||
// Keep the raw slug if decoding fails.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const remotePost = slug ? await fetchRemotePost(slug) : null
|
|
||||||
const post = remotePost ? normalizePost(remotePost.post) : null
|
|
||||||
const title = post?.data?.title || SITE.title
|
|
||||||
const dateValue = post?.data?.date ?? null
|
|
||||||
const meta = dateValue
|
|
||||||
? `${SITE.title} | ${formatDate(dateValue)}`
|
|
||||||
: SITE.title
|
|
||||||
const siteHost = new URL(SITE.href).host
|
|
||||||
const formattedDate = dateValue ? formatDate(dateValue) : null
|
|
||||||
const titleBlock = wrapAsBlock(title, 24, 2)
|
|
||||||
|
|
||||||
const badge = createElement('div', { style: badgeStyle }, [
|
|
||||||
createElement('span', { style: badgeDotStyle }),
|
|
||||||
siteHost,
|
|
||||||
])
|
|
||||||
|
|
||||||
const cardChildren = [
|
|
||||||
createElement('div', { style: gridStyle }),
|
|
||||||
badge,
|
|
||||||
createElement('div', { style: titleStyle }, titleBlock),
|
|
||||||
]
|
|
||||||
|
|
||||||
const metaRow = createElement('div', { style: metaRowStyle }, [
|
|
||||||
createElement('div', { style: metaLeftStyle }, [
|
|
||||||
createElement('div', { style: metaLabelStyle }, 'PUBLISHED'),
|
|
||||||
createElement(
|
|
||||||
'div',
|
|
||||||
{ style: metaValueStyle },
|
|
||||||
formattedDate ?? 'Fresh dispatches',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
createElement(
|
|
||||||
'div',
|
|
||||||
{ style: signatureStyle },
|
|
||||||
formattedDate ? `${SITE.title} · ${siteHost}` : meta,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
cardChildren.push(metaRow)
|
|
||||||
|
|
||||||
const accentRing = createElement('div', {
|
|
||||||
style: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: 520,
|
|
||||||
height: 520,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: '1px solid rgba(236, 72, 153, 0.45)',
|
|
||||||
right: -120,
|
|
||||||
top: -140,
|
|
||||||
transform: 'rotate(25deg)',
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const accentGlow = createElement('div', {
|
|
||||||
style: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: 420,
|
|
||||||
height: 420,
|
|
||||||
borderRadius: '50%',
|
|
||||||
left: -120,
|
|
||||||
bottom: -160,
|
|
||||||
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.35), transparent 65%)',
|
|
||||||
filter: 'blur(2px)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const card = createElement('div', { style: cardStyle }, cardChildren)
|
|
||||||
const root = createElement('div', { style: containerStyle }, [
|
|
||||||
accentGlow,
|
|
||||||
accentRing,
|
|
||||||
card,
|
|
||||||
])
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
width: WIDTH,
|
|
||||||
height: HEIGHT,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'image/png',
|
|
||||||
'Cache-Control':
|
|
||||||
'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import PageHead from '@/components/PageHead.astro'
|
|||||||
import PaginationComponent from '@/components/ui/pagination'
|
import PaginationComponent from '@/components/ui/pagination'
|
||||||
import { SITE } from '@/consts'
|
import { SITE } from '@/consts'
|
||||||
import Layout from '@/layouts/Layout.astro'
|
import Layout from '@/layouts/Layout.astro'
|
||||||
import { groupPostsByYear, normalizePost } from '@/lib/data-utils'
|
import { fetchPostsPage, groupPostsByYear } from '@/lib/data-utils'
|
||||||
|
|
||||||
// export async function getStaticPaths({
|
// export async function getStaticPaths({
|
||||||
// paginate,
|
// paginate,
|
||||||
@@ -22,22 +22,12 @@ const nowPage = parseInt(
|
|||||||
// use url param
|
// use url param
|
||||||
// const { page } = Astro.para
|
// const { page } = Astro.para
|
||||||
|
|
||||||
const page = await fetch(
|
const postsPage = await fetchPostsPage(nowPage, SITE.postsPerPage)
|
||||||
`https://notion-api.nvme0n1p.dev/v2/posts/?page=${nowPage}&length=${SITE.postsPerPage}`,
|
const page = {
|
||||||
)
|
data: postsPage.posts,
|
||||||
.then((res) => {
|
currentPage: nowPage,
|
||||||
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`)
|
lastPage: postsPage.totalPages,
|
||||||
return res.json()
|
}
|
||||||
})
|
|
||||||
.then((allPosts) => {
|
|
||||||
const totalPages = allPosts.length
|
|
||||||
const currentPage = nowPage
|
|
||||||
return {
|
|
||||||
data: allPosts.posts.map((post) => normalizePost(post)),
|
|
||||||
currentPage,
|
|
||||||
lastPage: totalPages,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// fetch page
|
// fetch page
|
||||||
if (page.lastPage < page.currentPage) {
|
if (page.lastPage < page.currentPage) {
|
||||||
console.log('redirect to 404')
|
console.log('redirect to 404')
|
||||||
|
|||||||
Reference in New Issue
Block a user