From 7a1787a5c6023cfecc6dca29f1d818771bf36ef4 Mon Sep 17 00:00:00 2001 From: LiBr Date: Sun, 10 May 2026 14:26:59 +0800 Subject: [PATCH] Update blog UI and image lightbox --- AGENTS.md | 26 +- THIRD_PARTY_NOTICES.md | 10 + astro.config.ts | 9 +- bun.lock | 1205 ++++++++++++----- package.json | 33 +- public/js/fslightbox.js | 1 - src/components/ImageLightbox.tsx | 389 ++++++ src/components/ThemeToggle.astro | 4 +- src/components/TwikooPageViewClient.tsx | 134 ++ .../comment/CommentAdminCommentsPanel.tsx | 403 ++++++ src/components/comment/CommentAvatarImage.tsx | 80 ++ src/components/notion/Bookmark.astro | 123 +- src/components/notion/BulletedListItems.astro | 21 +- src/components/notion/Callout.astro | 51 +- src/components/notion/Caption.astro | 18 +- src/components/notion/Code.astro | 58 +- src/components/notion/ColumnList.astro | 28 +- src/components/notion/Divider.astro | 11 +- src/components/notion/Embed.astro | 3 +- src/components/notion/Equation.astro | 8 +- src/components/notion/File.astro | 29 +- src/components/notion/Heading1.astro | 65 +- src/components/notion/Heading2.astro | 65 +- src/components/notion/Heading3.astro | 65 +- src/components/notion/Image.astro | 121 +- src/components/notion/LinkToPage.astro | 4 +- src/components/notion/Mention.astro | 52 +- src/components/notion/NumberedListItems.astro | 24 +- src/components/notion/Paragraph.astro | 42 +- src/components/notion/Quote.astro | 12 +- src/components/notion/Table.astro | 22 +- src/components/notion/TableOfContents.astro | 46 +- src/components/notion/ToDo.astro | 52 +- src/components/notion/Toggle.astro | 26 +- src/components/notion/Video.astro | 20 +- .../notion/annotations/Anchor.astro | 8 +- src/components/notion/annotations/Code.astro | 9 +- src/content.config.ts | 1 + src/env.d.ts | 4 + src/layouts/Layout.astro | 175 ++- src/lib/blog-helpers.ts | 18 +- src/lib/data-utils.ts | 144 +- src/lib/notion/api.ts | 37 +- src/lib/twikoo.ts | 381 ++++++ src/lib/utils.ts | 2 +- src/pages/blog/[...id].astro | 51 +- src/styles/global.css | 15 +- src/styles/page-transitions.css | 140 ++ src/styles/typography.css | 641 +++++++++ 49 files changed, 3665 insertions(+), 1221 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md delete mode 100644 public/js/fslightbox.js create mode 100644 src/components/ImageLightbox.tsx create mode 100644 src/components/TwikooPageViewClient.tsx create mode 100644 src/components/comment/CommentAdminCommentsPanel.tsx create mode 100644 src/components/comment/CommentAvatarImage.tsx create mode 100644 src/lib/twikoo.ts create mode 100644 src/styles/page-transitions.css diff --git a/AGENTS.md b/AGENTS.md index 726018c..8b72714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,10 @@ patch-package ## Tech Stack -- **Framework**: Astro 5.7.13 (SSR mode, output: 'server') -- **UI**: React 19.0.0 via @astrojs/react -- **Styling**: Tailwind CSS v4.1.7 via @tailwindcss/vite +- **Framework**: Astro 6.3.1 (SSR mode, output: 'server') +- **UI**: React 19.0.0 via @astrojs/react 5.0.4 +- **Styling**: Tailwind CSS v4.3.0 via @tailwindcss/vite 4.3.0 +- **Typography**: MiSans via the `misans` package, Geist Mono for code - **TypeScript**: 5.8.3 with strict mode enabled - **Content**: Notion API integration for blog posts - **UI Components**: shadcn/ui (new-york style, neutral base color) @@ -40,16 +41,19 @@ patch-package ## Code Style Guidelines ### Formatting (Prettier) + - Single quotes (`'`), no semicolons, trailing comma (es5 style) - Plugins: `prettier-plugin-astro`, `prettier-plugin-tailwindcss`, `prettier-plugin-astro-organize-imports` - Format with: `bun run prettier` ### TypeScript Configuration + - Strict mode enabled (`strictNullChecks: true`) - Path aliases: `@/*` → `./src/*` - JSX: `react-jsx` with React as import source ### Imports + - Use path alias `@/` for all internal imports: - `@/components/*` → components - `@/lib/*` → utility functions @@ -61,12 +65,14 @@ patch-package ### Component Patterns **Astro Components (.astro)**: + - Use for main layout and content components - Props interface defined in frontmatter: `export interface Props { ... }` - Use `class` prop for CSS classes (not `className`) - Extract props: `const { class: className } = Astro.props` **React Components (.tsx)**: + - Use for interactive UI components (shadcn/ui pattern) - Use `className` for CSS classes - Use `cn()` utility for conditional class merging @@ -74,12 +80,13 @@ patch-package - class-variance-authority (cva) for component variants **Example:** + ```tsx import { cn } from '@/lib/utils' import { cva } from 'class-variance-authority' const variants = cva('base-classes', { - variants: { variant: { default: '...', outline: '...' } } + variants: { variant: { default: '...', outline: '...' } }, }) function Component({ className, variant, ...props }) { @@ -90,6 +97,7 @@ function Component({ className, variant, ...props }) { ### Styling **Tailwind CSS**: + - Primary styling framework (v4, no config file) - Use `cn()` from `@/lib/utils` for merging conditional classes - Dark mode via `data-theme` attribute (not media query) @@ -97,35 +105,41 @@ function Component({ className, variant, ...props }) { - OKLCH color space for all colors **CSS Variables**: + - Light mode: `:root` selector - Dark mode: `[data-theme='dark']` selector - Semantic tokens: `--background`, `--foreground`, `--primary`, `--muted`, `--border`, `--ring` - Special tokens: `--notion-surface`, `--notion-border`, `--fg`, `--anchor-border` **Custom CSS**: + - `global.css`: Main stylesheet with Tailwind imports - `notion-color.css`: Notion integration colors - `typography.css`: Text styling - `syntax-coloring.css`: Code highlighting styles ### Type Definitions + - All shared types in `src/lib/interfaces.ts` - Notion API types: Block, Paragraph, Heading1-3, Image, Code, etc. - Use explicit types for all props and function parameters ### Utility Functions + - `cn()` from `@/lib/utils`: Merge CSS classes (clsx + tailwind-merge) - `formatDate()`, `readingTime()`, `calculateWordCountFromHtml()` in `@/lib/utils` - Blog helpers in `@/lib/blog-helpers.ts`: URL parsing, slug generation, Notion image handling - Style helpers in `@/lib/style-helpers.ts`: CSS class transformations ### Component Structure + - Layouts: `src/layouts/Layout.astro` - Pages: `src/pages/*.astro` and `src/pages/**/*.astro` - Components: `src/components/*.astro` and `src/components/ui/*.tsx` - Notion block components: `src/components/notion/*.astro` ### Naming Conventions + - Components: PascalCase (`Header.astro`, `Button.tsx`) - Files: PascalCase for components, kebab-case for utilities - CSS classes: Tailwind utilities, custom classes in kebab-case @@ -133,17 +147,20 @@ function Component({ className, variant, ...props }) { - Constants: UPPER_SNAKE_CASE (`SITE`, `ENABLE_LIGHTBOX`) ### Error Handling + - Use try-catch for async operations - Console errors for image URL parsing failures - Graceful null/undefined checks for optional properties - Return early for invalid data: `if (!block.Paragraph) return null` ### Inline Scripts + - Use `is:inline` for client-side scripts that need to execute immediately - Use standard ` diff --git a/src/lib/blog-helpers.ts b/src/lib/blog-helpers.ts index 6a8169c..7896a10 100644 --- a/src/lib/blog-helpers.ts +++ b/src/lib/blog-helpers.ts @@ -1,10 +1,4 @@ -import type { - Heading1, - Heading2, - Heading3, - RichText, - Text, -} from './interfaces' +import type { Heading1, Heading2, Heading3, RichText, Text } from './interfaces' function toSlug(text: string): string { return text @@ -16,9 +10,10 @@ function toSlug(text: string): string { .replace(/^-|-$/g, '') } -function extractHeadingText( - heading?: Heading1 | Heading2 | Heading3, -): { text: string; slug: string } { +function extractHeadingText(heading?: Heading1 | Heading2 | Heading3): { + text: string + slug: string +} { const parts = heading?.RichTexts ?? [] const plain = parts.map((r) => r.PlainText || r.Text?.Content || '').join('') const slug = toSlug(plain) || heading?.RichTexts?.[0]?.PlainText || '' @@ -116,9 +111,6 @@ export function richTextToPlainText(richTexts: RichText[] = []): string { .join('') } -export function getStaticFilePath(path: string): string { - return `${path}` -} export function buildText( content: string, link?: string, diff --git a/src/lib/data-utils.ts b/src/lib/data-utils.ts index 32ec9f5..fc21476 100644 --- a/src/lib/data-utils.ts +++ b/src/lib/data-utils.ts @@ -46,7 +46,7 @@ export type NotionBlockValue = { } export type NotionBlock = { - value: NotionBlockValue + value: NotionBlockValue | { role?: string; value?: NotionBlockValue | null } } export type NotionBlockMap = Record @@ -263,7 +263,7 @@ export async function fetchRemotePost( `/v2/posts/${encodeURI(slug)}?info=true`, ) if (!data.post) return null - return data + return { post: data.post } } catch (error) { if (error instanceof NotionApiError && error.status === 404) { return null @@ -389,7 +389,40 @@ export type RenderedRemoteContent = { wordCount: number } -function parseRichTexts(raw: any): RichText[] { +function getExternalObjectReference( + blockMap: NotionBlockMap, + blockId: string, +): { title?: string; url?: string } { + const block = getBlockValue(blockMap, blockId) + if (!block || block.type !== 'external_object_instance') { + return {} + } + + const attributes = Array.isArray(block.format?.attributes) + ? block.format.attributes + : [] + + const title = attributes.find( + (attribute: any) => + attribute?.id === 'title' || + attribute?.name === 'Name' || + attribute?.format?.type === 'title', + )?.values?.[0] + + const url = + typeof block.format?.uri === 'string' + ? block.format.uri + : typeof block.format?.original_url === 'string' + ? block.format.original_url + : undefined + + return { + title: typeof title === 'string' && title.trim() ? title.trim() : undefined, + url, + } +} + +function parseRichTexts(raw: any, blockMap: NotionBlockMap): RichText[] { if (!Array.isArray(raw)) return [] return raw.map((segment: any) => { @@ -407,6 +440,8 @@ function parseRichTexts(raw: any): RichText[] { let href: string | undefined let mentionPageId: string | undefined let equationExpression: string | undefined + let externalObjectId: string | undefined + let linkMention: { title: string; href: string } | undefined if (Array.isArray(decorations)) { for (const deco of decorations) { @@ -454,12 +489,41 @@ function parseRichTexts(raw: any): RichText[] { annotation.Color = value } break + case 'eoi': + if (typeof value === 'string') { + externalObjectId = value + } + break + case 'lm': + if ( + value && + typeof value.href === 'string' && + typeof value.title === 'string' + ) { + linkMention = { + href: value.href, + title: value.title, + } + } + break default: break } } } + const externalObject = externalObjectId + ? getExternalObjectReference(blockMap, externalObjectId) + : null + + if (!href && externalObject?.url) { + href = externalObject.url + } + + if (!href && linkMention) { + href = linkMention.href + } + if (equationExpression) { const richText: RichText = { Equation: { Expression: equationExpression }, @@ -481,7 +545,15 @@ function parseRichTexts(raw: any): RichText[] { return richText } - const richText = buildText(typeof text === 'string' ? text : '', href, { + const linkedMentionTitle = linkMention?.title ?? externalObject?.title + const textContent = + linkedMentionTitle && typeof text === 'string' && text.trim() === '‣' + ? linkedMentionTitle + : typeof text === 'string' + ? text + : '' + + const richText = buildText(textContent, href, { Color: annotation.Color, Bold: annotation.Bold, Italic: annotation.Italic, @@ -594,6 +666,30 @@ function countWords(blocks: Block[]): number { return total } +function unwrapNotionBlockValue( + block: NotionBlock | undefined, +): NotionBlockValue | null { + const raw = block?.value as any + if (!raw) return null + if ( + raw && + typeof raw == 'object' && + 'value' in raw && + raw.value && + typeof raw.value == 'object' + ) { + return raw.value as NotionBlockValue + } + return raw as NotionBlockValue +} + +function getBlockValue( + blockMap: NotionBlockMap, + blockId: string, +): NotionBlockValue | null { + return unwrapNotionBlockValue(blockMap[blockId]) +} + function buildBlocks( contentIds: string[], blockMap: NotionBlockMap, @@ -604,7 +700,7 @@ function buildBlocks( while (i < contentIds.length) { const currentId = contentIds[i] - const current = blockMap[currentId]?.value + const current = getBlockValue(blockMap, currentId) if (!current) { i++ continue @@ -615,9 +711,9 @@ function buildBlocks( while ( i < contentIds.length && - blockMap[contentIds[i]]?.value?.type === targetType + getBlockValue(blockMap, contentIds[i])?.type === targetType ) { - const item = blockMap[contentIds[i]]?.value + const item = getBlockValue(blockMap, contentIds[i]) const childIds = Array.isArray(item?.content) ? item.content : [] const children = buildBlocks(childIds, blockMap, headingBlocks) @@ -627,7 +723,7 @@ function buildBlocks( Type: 'bulleted_list_item', HasChildren: children.length > 0, BulletedListItem: { - RichTexts: parseRichTexts(item?.properties?.title), + RichTexts: parseRichTexts(item?.properties?.title, blockMap), Color: typeof item?.format?.block_color === 'string' ? item?.format?.block_color @@ -641,7 +737,7 @@ function buildBlocks( Type: 'numbered_list_item', HasChildren: children.length > 0, NumberedListItem: { - RichTexts: parseRichTexts(item?.properties?.title), + RichTexts: parseRichTexts(item?.properties?.title, blockMap), Color: typeof item?.format?.block_color === 'string' ? item?.format?.block_color @@ -679,7 +775,7 @@ function convertBlock( blockMap: NotionBlockMap, headingBlocks: Block[], ): Block | null { - const block = blockMap[blockId]?.value + const block = getBlockValue(blockMap, blockId) if (!block) return null const childIds = Array.isArray(block.content) ? block.content : [] @@ -697,7 +793,7 @@ function convertBlock( Type: 'paragraph', HasChildren: children.length > 0, Paragraph: { - RichTexts: parseRichTexts(block.properties?.title), + RichTexts: parseRichTexts(block.properties?.title, blockMap), Color: color, Children: children.length > 0 ? children : undefined, }, @@ -707,7 +803,7 @@ function convertBlock( case 'sub_header': case 'sub_sub_header': { const heading = { - RichTexts: parseRichTexts(block.properties?.title), + RichTexts: parseRichTexts(block.properties?.title, blockMap), Color: color, IsToggleable: false, Children: children.length > 0 ? children : undefined, @@ -744,8 +840,8 @@ function convertBlock( Type: 'code', HasChildren: false, Code: { - Caption: parseRichTexts(block.properties?.caption), - RichTexts: parseRichTexts(block.properties?.title), + Caption: parseRichTexts(block.properties?.caption, blockMap), + RichTexts: parseRichTexts(block.properties?.title, blockMap), Language: block.properties?.language?.[0]?.[0]?.toString()?.toLowerCase() || 'plain text', @@ -762,7 +858,7 @@ function convertBlock( Type: 'image', HasChildren: false, Image: { - Caption: parseRichTexts(block.properties?.caption), + Caption: parseRichTexts(block.properties?.caption, blockMap), Type: 'external', External: { Url: processed }, Width: block.format?.block_width, @@ -779,7 +875,7 @@ function convertBlock( Type: 'quote', HasChildren: children.length > 0, Quote: { - RichTexts: parseRichTexts(block.properties?.title), + RichTexts: parseRichTexts(block.properties?.title, blockMap), Color: color, Children: children.length > 0 ? children : undefined, }, @@ -791,7 +887,7 @@ function convertBlock( Type: 'callout', HasChildren: children.length > 0, Callout: { - RichTexts: parseRichTexts(block.properties?.title), + RichTexts: parseRichTexts(block.properties?.title, blockMap), Icon: block.format?.page_icon ? { Type: 'emoji', Emoji: block.format?.page_icon } : null, @@ -837,7 +933,7 @@ function convertBlock( Type: 'bookmark', HasChildren: false, Bookmark: { - Caption: parseRichTexts(block.properties?.title), + Caption: parseRichTexts(block.properties?.title, blockMap), Url: url, }, } @@ -874,7 +970,7 @@ function convertBlock( Type: 'video', HasChildren: false, Video: { - Caption: parseRichTexts(block.properties?.caption), + Caption: parseRichTexts(block.properties?.caption, blockMap), Type: 'external', External: { Url: url }, }, @@ -888,7 +984,7 @@ function convertBlock( Type: 'to_do', HasChildren: children.length > 0, ToDo: { - RichTexts: parseRichTexts(block.properties?.title), + RichTexts: parseRichTexts(block.properties?.title, blockMap), Checked: checked, Color: color, Children: children.length > 0 ? children : undefined, @@ -901,7 +997,7 @@ function convertBlock( Type: 'toggle', HasChildren: children.length > 0, Toggle: { - RichTexts: parseRichTexts(block.properties?.title), + RichTexts: parseRichTexts(block.properties?.title, blockMap), Color: color, Children: children.length > 0 ? children : undefined, }, @@ -910,7 +1006,7 @@ function convertBlock( case 'column_list': { const columns = childIds.map((id) => { - const col = blockMap[id]?.value + const col = getBlockValue(blockMap, id) const colChildren = Array.isArray(col?.content) ? buildBlocks(col?.content, blockMap, headingBlocks) : [] @@ -976,11 +1072,11 @@ export function renderRemoteBlockMap( rootId: string, ): RenderedRemoteContent { const headingBlocks: Block[] = [] - const root = blockMap[rootId]?.value + const root = getBlockValue(blockMap, rootId) const fallbackRoot = root || Object.values(blockMap) - .map((b) => b.value) + .map((b) => unwrapNotionBlockValue(b)) .find( (value) => Array.isArray(value?.content) && diff --git a/src/lib/notion/api.ts b/src/lib/notion/api.ts index c300823..efa0b44 100644 --- a/src/lib/notion/api.ts +++ b/src/lib/notion/api.ts @@ -1,5 +1,3 @@ -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] @@ -22,24 +20,25 @@ function normalizeBaseUrl(value: string): string { return value.replace(/\/+$/, '') } -export function getNotionApiServerBaseUrl(): string { - return normalizeBaseUrl( +function getNotionApiBaseUrl(): string { + const baseUrl = 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, - ) + import.meta.env.NOTION_API_BASE_URL || + 'https://nvme0n1p.dev' + + if (!baseUrl) { + throw new Error('Missing NOTION_API_BASE_URL') + } + + return normalizeBaseUrl(baseUrl) +} + +export function getNotionApiServerBaseUrl(): string { + return getNotionApiBaseUrl() } 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, - ) + return getNotionApiBaseUrl() } export function buildNotionApiUrl(path: string): string { @@ -51,7 +50,11 @@ export async function fetchNotionApiJson(path: string): Promise { const res = await fetch(url) if (!res.ok) { - throw new NotionApiError(`Failed to fetch ${url}: ${res.status}`, res.status, url) + throw new NotionApiError( + `Failed to fetch ${url}: ${res.status}`, + res.status, + url, + ) } return (await res.json()) as T diff --git a/src/lib/twikoo.ts b/src/lib/twikoo.ts new file mode 100644 index 0000000..5d98800 --- /dev/null +++ b/src/lib/twikoo.ts @@ -0,0 +1,381 @@ +import md5 from 'blueimp-md5' + +const DEFAULT_TWIKOO_BASE_URL = 'https://nvme0n1p.dev/api/comments' +const EMPTY_MAIL_MD5_HASH = md5('') +const EMPTY_MAIL_SHA256_HASH = + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + +function readRuntimeEnv(key: string): string | undefined { + if (typeof process === 'undefined') return undefined + const value = process.env[key] + return value && value.trim() ? value.trim() : undefined +} + +function normalizeBaseUrl(value: string): string { + return value.replace(/\/+$/, '') +} + +function readAccessToken(): string | undefined { + if (typeof localStorage === 'undefined') return undefined + const value = localStorage.getItem('twikoo-access-token') + return value && value.trim() ? value.trim() : undefined +} + +function writeAccessToken(value?: string): void { + if (!value || typeof localStorage === 'undefined') return + localStorage.setItem('twikoo-access-token', value) +} + +export interface TwikooConfig { + DISPLAYED_FIELDS?: string + REQUIRED_FIELDS?: string + MASTER_TAG?: string + SHOW_ORDER?: string + SHOW_DISLIKE?: string + COMMENT_BG_IMG?: string + IS_ADMIN?: boolean + VERSION?: string + [key: string]: string | boolean | undefined +} + +export interface TwikooComment { + id: string + nick: string + comment: string + created: number + mailMd5?: string + avatar?: string + link?: string + master?: boolean + top?: boolean + isSpam?: boolean + pid?: string + rid?: string + ruser?: string + browser?: string + os?: string + ipRegion?: string + ups?: string[] + downs?: string[] + liked?: boolean + disliked?: boolean + replies: TwikooComment[] +} + +export interface TwikooCommentListResult { + data: TwikooComment[] + count: number + more: boolean +} + +export interface TwikooProfile { + nick: string + mail: string + link: string +} + +export type TwikooSort = 'newest' | 'oldest' | 'popular' + +export function getTwikooPublicBaseUrl(): string { + return normalizeBaseUrl( + readRuntimeEnv('PUBLIC_TWIKOO_BASE_URL') || + readRuntimeEnv('TWIKOO_BASE_URL') || + import.meta.env.PUBLIC_TWIKOO_BASE_URL || + import.meta.env.TWIKOO_BASE_URL || + DEFAULT_TWIKOO_BASE_URL, + ) +} + +export async function callTwikoo( + event: string, + payload: Record = {}, +): Promise { + const res = await fetch(getTwikooPublicBaseUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event, + accessToken: readAccessToken(), + ...payload, + }), + }) + + const result = (await res.json()) as T & { + accessToken?: string + message?: string + } + + if (Object.prototype.hasOwnProperty.call(result, 'accessToken')) { + writeAccessToken(result.accessToken) + } + + if (!res.ok) { + throw new Error(result.message || `Twikoo request failed: ${res.status}`) + } + + return result +} + +export function getStoredTwikooProfile(): TwikooProfile { + if (typeof localStorage === 'undefined') { + return { nick: '', mail: '', link: '' } + } + + try { + const raw = localStorage.getItem('twikoo') + if (!raw) return { nick: '', mail: '', link: '' } + const parsed = JSON.parse(raw) as Partial + return { + nick: parsed.nick || '', + mail: parsed.mail || '', + link: parsed.link || '', + } + } catch { + return { nick: '', mail: '', link: '' } + } +} + +export function setStoredTwikooProfile(profile: TwikooProfile): void { + if (typeof localStorage === 'undefined') return + localStorage.setItem('twikoo', JSON.stringify(profile)) +} + +export function setTwikooAccessToken(value?: string): void { + if (!value || typeof localStorage === 'undefined') return + localStorage.setItem('twikoo-access-token', value) +} + +export function clearTwikooAccessToken(): void { + if (typeof localStorage === 'undefined') return + localStorage.removeItem('twikoo-access-token') +} + +function normalizeMail(mail: string): string { + return String(mail).trim().toLowerCase() +} + +function isQQ(mail: string): boolean { + return ( + /^[1-9][0-9]{4,10}$/.test(mail) || /^[1-9][0-9]{4,10}@qq.com$/i.test(mail) + ) +} + +function getQQAvatar(qq: string): string { + const qqNum = qq.replace(/@qq.com/gi, '') + return `https://thirdqq.qlogo.cn/g?b=sdk&nk=${qqNum}&s=140` +} + +function getAvatarConfig(options: { + config: TwikooConfig | null + nick?: string +}): { gravatarCdn: string; defaultGravatar: string } { + const nick = options.nick?.trim() || '' + + const gravatarCdn = + typeof options.config?.GRAVATAR_CDN === 'string' && + options.config.GRAVATAR_CDN + ? options.config.GRAVATAR_CDN + : 'weavatar.com' + + const defaultGravatar = + typeof options.config?.DEFAULT_GRAVATAR === 'string' && + options.config.DEFAULT_GRAVATAR + ? options.config.DEFAULT_GRAVATAR + : `initials&name=${nick}` + + return { + gravatarCdn, + defaultGravatar, + } +} + +async function sha256Hex(input: string): Promise { + const encoded = new TextEncoder().encode(input) + const digest = await crypto.subtle.digest('SHA-256', encoded) + return Array.from(new Uint8Array(digest)) + .map((value) => value.toString(16).padStart(2, '0')) + .join('') +} + +export async function getTwikooAvatarUrl(options: { + config: TwikooConfig | null + mail?: string + nick?: string +}): Promise { + const mail = options.mail?.trim() + const { gravatarCdn, defaultGravatar } = getAvatarConfig(options) + + if (!mail) { + const emptyHash = + gravatarCdn === 'cravatar.cn' ? md5('') : await sha256Hex('') + + return `https://${gravatarCdn}/avatar/${emptyHash}?d=${defaultGravatar}` + } + + if (isQQ(mail)) { + return getQQAvatar(mail) + } + + const normalizedMail = normalizeMail(mail) + const hash = + gravatarCdn === 'cravatar.cn' + ? md5(normalizedMail) + : await sha256Hex(normalizedMail) + + return `https://${gravatarCdn}/avatar/${hash}?d=${defaultGravatar}` +} + +export function getTwikooAvatarCandidates(options: { + config: TwikooConfig | null + avatar?: string + mail?: string + mailMd5?: string + nick?: string +}): string[] { + const { gravatarCdn, defaultGravatar } = getAvatarConfig(options) + const candidates: string[] = [] + const pushCandidate = (value?: string) => { + const normalized = value?.trim() + if (!normalized || candidates.includes(normalized)) return + candidates.push(normalized) + } + + pushCandidate(options.avatar) + + if (options.mail && isQQ(options.mail)) { + pushCandidate(getQQAvatar(options.mail)) + } + + if (options.mailMd5) { + pushCandidate( + `https://${gravatarCdn}/avatar/${options.mailMd5}?d=${defaultGravatar}`, + ) + } + + const emptyHash = + gravatarCdn === 'cravatar.cn' ? EMPTY_MAIL_MD5_HASH : EMPTY_MAIL_SHA256_HASH + + pushCandidate( + `https://${gravatarCdn}/avatar/${emptyHash}?d=${defaultGravatar}`, + ) + + return candidates +} + +function readFieldSetting( + configValue: string | boolean | undefined, + field: 'nick' | 'mail' | 'link', + fallback: boolean, +): boolean { + if (typeof configValue !== 'string' || !configValue) return fallback + return configValue.includes(field) +} + +export function isTwikooFieldDisplayed( + config: TwikooConfig | null, + field: 'nick' | 'mail' | 'link', +): boolean { + return readFieldSetting(config?.DISPLAYED_FIELDS, field, true) +} + +export function isTwikooFieldRequired( + config: TwikooConfig | null, + field: 'nick' | 'mail' | 'link', +): boolean { + const fallback = field === 'link' ? false : true + return readFieldSetting(config?.REQUIRED_FIELDS, field, fallback) +} + +export function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +export function textToTwikooHtml(value: string): string { + const normalized = value.trim().replace(/\r\n/g, '\n') + if (!normalized) return '' + + return normalized + .split(/\n{2,}/) + .map( + (paragraph) => `

${escapeHtml(paragraph).replace(/\n/g, '
')}

`, + ) + .join('') +} + +export function formatCommentTime(timestamp: number): string { + const now = Date.now() + const diff = timestamp - now + const abs = Math.abs(diff) + const formatter = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }) + + if (abs < 60_000) return formatter.format(Math.round(diff / 1000), 'second') + if (abs < 3_600_000) { + return formatter.format(Math.round(diff / 60_000), 'minute') + } + if (abs < 86_400_000) { + return formatter.format(Math.round(diff / 3_600_000), 'hour') + } + if (abs < 2_592_000_000) { + return formatter.format(Math.round(diff / 86_400_000), 'day') + } + + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(timestamp) +} + +export function formatCommentAbsoluteTime(timestamp: number): string { + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(timestamp) +} + +export function normalizeCommentLink(link?: string): string | undefined { + if (!link) return undefined + if (link.startsWith('http://') || link.startsWith('https://')) return link + return `http://${link}` +} + +export function getCommentPageCursor( + comments: TwikooComment[], +): number | undefined { + const sortable = comments + .filter((comment) => !comment.top) + .map((comment) => comment.created) + if (sortable.length === 0) return undefined + return sortable.sort((a, b) => a - b)[0] +} + +export function patchCommentTree( + comments: TwikooComment[], + targetId: string, + updater: (comment: TwikooComment) => TwikooComment, +): TwikooComment[] { + return comments.map((comment) => { + if (comment.id === targetId) { + return updater(comment) + } + + if (!comment.replies.length) return comment + + return { + ...comment, + replies: patchCommentTree(comment.replies, targetId, updater), + } + }) +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 052565d..7370760 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' -import type { ImageMetadata } from 'astro:assets' +import type { ImageMetadata } from 'astro' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) diff --git a/src/pages/blog/[...id].astro b/src/pages/blog/[...id].astro index b1ef752..0b8b5d2 100644 --- a/src/pages/blog/[...id].astro +++ b/src/pages/blog/[...id].astro @@ -9,6 +9,7 @@ import SubpostsHeader from '@/components/SubpostsHeader.astro' import SubpostsSidebar from '@/components/SubpostsSidebar.astro' import TOCHeader from '@/components/TOCHeader.astro' import TOCSidebar from '@/components/TOCSidebar.astro' +import TwikooPageView from '@/components/TwikooPageView.astro' import { badgeVariants } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import Layout from '@/layouts/Layout.astro' @@ -52,6 +53,7 @@ let hasChildPosts = false let subpostCount = 0 let tocSections: TOCSection[] = [] let heroImage: any = null +let heroImageLightboxHref: string | null = null try { const remote = await fetchRemotePostContent(currentPostId) @@ -84,6 +86,8 @@ try { : [] : await getTOCSections(currentPostId) heroImage = post?.data?.banner ?? post?.data?.image ?? null + heroImageLightboxHref = + typeof heroImage === 'string' ? heroImage : (heroImage?.src ?? null) } catch (error) { console.error('blog page render failed:', error) return Astro.rewrite('/500') @@ -142,15 +146,31 @@ export const prerender = false { - heroImage && ( - {post.data.title} - ) + heroImage && + (heroImageLightboxHref ? ( + + {post.data.title} + + ) : ( + {post.data.title} + )) }
@@ -200,6 +220,12 @@ export const prerender = false {formatDate(post.data.date)} +
+ +
+ { subpostCount > 0 && (
@@ -213,7 +239,7 @@ export const prerender = false { post.data.tags && post.data.tags.length > 0 && - post.data.tags.map((tag) => ( + post.data.tags.map((tag: string) => ( +
{ remoteContent ? ( remoteContent.blocks.length > 0 ? ( diff --git a/src/styles/global.css b/src/styles/global.css index 53791a8..8a82aeb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,3 +1,8 @@ +@import 'misans/lib/Normal/MiSans-Regular.min.css'; +@import 'misans/lib/Normal/MiSans-Medium.min.css'; +@import 'misans/lib/Normal/MiSans-Semibold.min.css'; +@import 'misans/lib/Normal/MiSans-Bold.min.css'; +@import 'misans/lib/Normal/MiSans-Heavy.min.css'; @import 'tailwindcss'; @import 'katex/dist/katex.min.css'; @@ -5,7 +10,7 @@ @theme inline { --font-sans: - Geist, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + MiSans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-mono: Geist Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, @@ -27,14 +32,6 @@ --color-ring: var(--ring); } -@font-face { - font-family: 'Geist'; - src: url('/fonts/GeistVF.woff2') format('woff2-variations'); - font-weight: 100 900; - font-style: normal; - font-display: swap; -} - @font-face { font-family: 'Geist Mono'; src: url('/fonts/GeistMonoVF.woff2') format('woff2-variations'); diff --git a/src/styles/page-transitions.css b/src/styles/page-transitions.css new file mode 100644 index 0000000..dfeaa91 --- /dev/null +++ b/src/styles/page-transitions.css @@ -0,0 +1,140 @@ +@keyframes page-content-forward-in { + from { + opacity: 0; + filter: blur(6px); + transform: translate3d(0, 0.875rem, 0) scale(0.992); + } + + to { + opacity: 1; + filter: blur(0); + transform: translate3d(0, 0, 0) scale(1); + } +} + +@keyframes page-content-forward-out { + from { + opacity: 1; + filter: blur(0); + transform: translate3d(0, 0, 0) scale(1); + } + + to { + opacity: 0; + filter: blur(4px); + transform: translate3d(0, -0.625rem, 0) scale(0.996); + } +} + +@keyframes page-content-back-in { + from { + opacity: 0; + filter: blur(6px); + transform: translate3d(0, -0.625rem, 0) scale(0.996); + } + + to { + opacity: 1; + filter: blur(0); + transform: translate3d(0, 0, 0) scale(1); + } +} + +@keyframes page-content-back-out { + from { + opacity: 1; + filter: blur(0); + transform: translate3d(0, 0, 0) scale(1); + } + + to { + opacity: 0; + filter: blur(4px); + transform: translate3d(0, 0.875rem, 0) scale(0.992); + } +} + +@keyframes page-chrome-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes page-chrome-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +::view-transition-old(root), +::view-transition-new(root), +::view-transition-old(page-content), +::view-transition-new(page-content), +::view-transition-old(site-header), +::view-transition-new(site-header), +::view-transition-old(site-footer), +::view-transition-new(site-footer) { + mix-blend-mode: normal; +} + +::view-transition-group(page-content) { + animation-duration: 315ms; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +.page-transition-progress { + --page-transition-progress: 0%; + + position: fixed; + inset: 0 0 auto; + z-index: 100; + height: 2px; + overflow: hidden; + opacity: 0; + pointer-events: none; + transform: translateZ(0); + transition: opacity 160ms ease; +} + +.page-transition-progress::before { + position: absolute; + inset: 0; + background: color-mix(in oklab, var(--foreground) 14%, transparent); + content: ''; +} + +.page-transition-progress__bar { + width: var(--page-transition-progress); + height: 100%; + background: linear-gradient( + 90deg, + var(--foreground), + color-mix(in oklab, var(--foreground) 70%, var(--background)) + ); + box-shadow: 0 0 18px color-mix(in oklab, var(--foreground) 45%, transparent); + transition: width 420ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.page-transition-progress.is-active { + opacity: 1; +} + +.page-transition-progress.is-finishing { + opacity: 0; + transition-duration: 220ms; +} + +@media (prefers-reduced-motion: reduce) { + .page-transition-progress, + .page-transition-progress__bar { + transition-duration: 1ms; + } +} diff --git a/src/styles/typography.css b/src/styles/typography.css index 0a5a133..a4007eb 100644 --- a/src/styles/typography.css +++ b/src/styles/typography.css @@ -144,6 +144,647 @@ @apply text-foreground/80 my-6 overflow-x-auto overflow-y-hidden py-2 tracking-normal; } } + + .notion-renderer { + --fg-color: var(--foreground); + --fg-color-0: color-mix(in oklab, var(--foreground) 9%, transparent); + --fg-color-1: color-mix(in oklab, var(--foreground) 16%, transparent); + --fg-color-2: color-mix(in oklab, var(--foreground) 40%, transparent); + --fg-color-3: color-mix(in oklab, var(--foreground) 60%, transparent); + --fg-color-4: var(--foreground); + --fg-color-5: color-mix(in oklab, var(--foreground) 3%, transparent); + --fg-color-6: color-mix(in oklab, var(--foreground) 80%, transparent); + --fg-color-icon: var(--foreground); + --bg-color: var(--background); + --bg-color-0: color-mix(in oklab, var(--muted) 55%, transparent); + --bg-color-1: color-mix(in oklab, var(--muted) 52%, var(--background)); + --bg-color-2: color-mix(in oklab, var(--muted) 68%, transparent); + --select-color-1: color-mix(in oklab, #2daadb 30%, transparent); + --notion-max-width: 720px; + + color: var(--fg-color); + caret-color: var(--fg-color); + font-family: inherit; + font-size: 16px; + line-height: 1.5; + } + + .notion-renderer * { + margin-block-start: 0; + margin-block-end: 0; + } + + .notion-renderer *::selection { + background: var(--select-color-1); + } + + .notion-renderer > * { + padding: 3px 0; + } + + .notion-renderer :where(.notion-text, .notion-blank) { + width: 100%; + padding: 3px 2px !important; + margin: 1px 0 !important; + color: var(--fg-color); + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-text) { + min-height: calc(1.5em + 6px); + } + + .notion-renderer :where(.notion-blank) { + min-height: 1rem; + } + + .notion-renderer :where(.notion-text-children) { + display: flex; + width: 100%; + flex-direction: column; + padding-left: 1.5em; + } + + .notion-renderer :where(.notion-h) { + position: relative; + width: 100%; + max-width: 100%; + margin-bottom: 1px; + padding: 3px 2px; + color: var(--fg-color); + font-weight: 600; + line-height: 1.3; + scroll-margin-top: 8rem; + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-h1) { + margin-top: 1.08em; + font-size: 1.575em; + } + + .notion-renderer :where(.notion-h2) { + margin-top: 1.1em; + font-size: 1.4em; + } + + .notion-renderer :where(.notion-h3) { + margin-top: 1em; + font-size: 1.25em; + } + + .notion-renderer :where(.notion-h:first-child) { + margin-top: 0; + } + + .notion-renderer :where(.notion-heading-link) { + color: inherit; + text-decoration: none; + border: 0; + } + + .notion-renderer :where(.notion-heading-link:hover) { + color: inherit; + } + + .notion-renderer :where(.notion-link) { + color: inherit; + word-break: break-word; + text-decoration: inherit; + border-bottom: 0.05em solid var(--fg-color-2) !important; + opacity: 0.7; + transition: + border-color 100ms ease-in, + opacity 100ms ease-in; + } + + .notion-renderer :where(.notion-link:hover) { + border-color: var(--fg-color-6) !important; + opacity: 1; + } + + .notion-renderer :where(.notion-inline-code) { + border: 0 !important; + border-radius: 3px; + background: var(--bg-color-2) !important; + color: #eb5757; + font-family: + SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 85%; + padding: 0.2em 0.4em; + } + + .notion-renderer :where(.notion-quote) { + display: block; + width: 100%; + margin: 6px 0; + padding: 0.2em 0.9em; + border-left: 3px solid currentcolor; + color: var(--fg-color); + font-size: 1.2em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-hr) { + width: 100%; + margin: 6px 0; + padding: 0; + border: 0; + border-top: 1px solid var(--notion-border) !important; + } + + .notion-renderer :where(.notion-list) { + width: 100%; + margin: 0.6em 0; + margin-left: 0; + padding-inline-start: 1.4em; + color: var(--fg-color); + } + + .notion-renderer :where(.notion-list-disc) { + list-style-type: disc; + } + + .notion-renderer :where(.notion-list-disc.list-circle) { + list-style-type: circle; + } + + .notion-renderer :where(.notion-list-disc.list-square) { + list-style-type: square; + } + + .notion-renderer :where(.notion-list-numbered) { + padding-inline-start: 1.6em; + list-style-type: decimal; + } + + .notion-renderer :where(.notion-list .notion-list) { + margin-top: 0.2em; + margin-bottom: 0; + } + + .notion-renderer :where(.notion-list-item) { + padding: 1px 0 1px 0.1em; + color: var(--fg-color); + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-list-item-content) { + display: inline; + } + + .notion-renderer :where(.notion-asset-wrapper) { + display: flex; + align-self: center; + width: 100%; + min-width: 100%; + max-width: 100%; + flex-direction: column; + margin: 0.5rem 0; + } + + .notion-renderer :where(.notion-asset-link) { + display: block; + cursor: zoom-in; + } + + .notion-renderer :where(.notion-image) { + display: block; + width: 100%; + max-width: 100%; + height: auto !important; + margin: auto; + border-radius: 1px; + } + + .notion-renderer :where(.notion-asset-caption) { + display: flex; + padding: 6px 0 6px 2px; + color: var(--fg-color-3); + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-asset-caption > div) { + width: 0; + flex-grow: 1; + } + + .notion-renderer :where(.notion-asset-wrapper-video > div:first-child) { + width: 100%; + } + + .notion-renderer :where(.notion-asset-wrapper-video iframe) { + width: 100%; + height: 340px; + } + + .notion-renderer :where(.notion-callout) { + display: inline-flex; + box-sizing: border-box; + width: 100%; + margin: 4px 0; + padding: 16px 16px 16px 12px; + align-items: flex-start; + border: 1px solid var(--fg-color-0); + border-radius: 3px; + background: var(--notion-surface); + } + + .notion-renderer :where(.notion-callout .notion-page-icon) { + width: 24px; + height: 24px; + margin: 0 8px 0 0; + flex: 0 0 auto; + font-size: 1em; + line-height: 1em; + } + + .notion-renderer :where(.notion-callout .notion-page-icon img) { + width: 1.2rem; + height: 1.2rem; + } + + .notion-renderer :where(.notion-callout-text) { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-callout-text .notion-text) { + min-height: 0; + padding: 0 !important; + margin: 0 !important; + line-height: inherit !important; + } + + .notion-renderer :where(.notion-toggle) { + width: 100%; + padding: 3px 2px; + color: var(--fg-color); + } + + .notion-renderer :where(.notion-toggle > summary) { + cursor: pointer; + outline: none; + } + + .notion-renderer :where(.notion-toggle > summary .notion-h) { + display: inline; + margin: 0; + padding: 0; + } + + .notion-renderer :where(.notion-toggle-children) { + margin-left: 1.1em; + } + + .notion-renderer :where(.notion-row) { + display: flex; + width: 100%; + max-width: 100%; + overflow: hidden; + } + + .notion-renderer :where(.notion-column) { + display: flex; + min-width: 0; + flex: 1 1 180px; + flex-direction: column; + padding-top: 12px; + padding-bottom: 12px; + } + + .notion-renderer :where(.notion-column + .notion-column) { + margin-left: min(32px, 4vw); + } + + .notion-renderer :where(.notion-bookmark) { + display: flex; + width: 100%; + margin: 4px 0; + overflow: hidden; + border: 1px solid var(--fg-color-1); + border-radius: 3px; + box-sizing: border-box; + text-decoration: none; + user-select: none; + } + + .notion-renderer :where(.notion-bookmark > a) { + display: flex; + width: 100%; + color: inherit; + text-decoration: none; + } + + .notion-renderer :where(.notion-bookmark > a:hover) { + background: var(--bg-color-0); + } + + .notion-renderer :where(.notion-bookmark > a > div:first-child) { + min-width: 0; + flex: 4 1 180px; + padding: 12px 14px 14px; + overflow: hidden; + color: var(--fg-color); + text-align: left; + } + + .notion-renderer :where(.notion-bookmark-title) { + min-height: 24px; + margin-bottom: 2px; + overflow: hidden; + font-size: 14px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .notion-renderer :where(.notion-bookmark-description) { + height: 32px; + overflow: hidden; + font-size: 12px; + line-height: 16px; + opacity: 0.8; + } + + .notion-renderer :where(.notion-bookmark-link) { + display: flex; + width: min(20rem, 100%); + margin-top: 6px; + align-items: center; + } + + .notion-renderer :where(.notion-bookmark-link > img) { + width: 16px; + min-width: 16px; + height: 16px; + margin-right: 6px; + } + + .notion-renderer :where(.notion-bookmark-link > div) { + overflow: hidden; + color: var(--fg-color); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .notion-renderer :where(.no-metadata > a) { + border-bottom: 0.05em solid var(--anchor-border); + opacity: 0.7; + } + + .notion-renderer :where(.notion-table-wrapper) { + width: 100%; + margin: 4px 0; + overflow-x: auto; + } + + .notion-renderer :where(.notion-table-wrapper table) { + width: max-content; + min-width: 100%; + border-collapse: collapse; + font-size: 14px; + } + + .notion-renderer :where(.notion-table-wrapper th, .notion-table-wrapper td) { + min-width: 120px; + padding: 7px 9px; + border: 1px solid var(--fg-color-1); + color: var(--fg-color); + font-weight: normal; + vertical-align: top; + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-table-wrapper th) { + background: var(--bg-color-1); + font-weight: 500; + } + + .notion-renderer :where(.notion-table-of-contents) { + width: 100%; + margin: 4px 0; + padding: 0.5rem; + background: var(--notion-surface); + } + + .notion-renderer :where(.notion-table-of-contents-item) { + display: flex; + width: 100%; + padding: 6px 2px; + align-items: center; + overflow: hidden; + color: var(--fg-color); + cursor: pointer; + font-size: 15px; + line-height: 1.2; + opacity: 0.9; + text-decoration: none; + text-overflow: ellipsis; + transition: background 20ms ease-in 0s; + user-select: none; + white-space: nowrap; + } + + .notion-renderer :where(.notion-table-of-contents-item:hover) { + background: var(--bg-color-0); + } + + .notion-renderer :where(.notion-table-of-contents-item-indent-level-1) { + padding-left: 1.5rem; + font-size: 14px; + } + + .notion-renderer :where(.notion-table-of-contents-item-indent-level-2) { + padding-left: 3rem; + font-size: 13px; + } + + .notion-renderer :where(.notion-table-of-contents-item-body) { + overflow: hidden; + border-bottom: 1px solid var(--fg-color-1); + text-overflow: ellipsis; + } + + .notion-renderer :where(.notion-to-do) { + display: flex; + width: 100%; + flex-direction: column; + } + + .notion-renderer :where(.notion-to-do-item) { + display: grid; + width: 100%; + min-height: calc(1.5em + 6px); + grid-template-columns: max-content minmax(0, 1fr); + align-items: start; + padding-left: 2px; + color: var(--fg-color); + } + + .notion-renderer :where(.notion-property-checkbox) { + width: 1rem; + height: 1rem; + margin: 0.28em 8px 0 0; + accent-color: var(--fg-color); + } + + .notion-renderer :where(.notion-to-do-body) { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + } + + .notion-renderer :where(.notion-to-do-checked) { + opacity: 0.375; + text-decoration: line-through; + } + + .notion-renderer :where(.notion-to-do-children) { + grid-column: 2; + padding-left: 1.5em; + } + + .notion-renderer :where(.notion-page-link) { + display: flex; + width: 100%; + min-height: 30px; + margin: 1px 0; + color: var(--fg-color); + text-decoration: underline; + transition: background 120ms ease-in 0s; + } + + .notion-renderer :where(.notion-page-link:hover) { + background: var(--bg-color-0); + } + + .notion-renderer :where(.notion-page-icon) { + margin: 2px 4px 0 2px; + color: var(--fg-color-icon); + fill: var(--fg-color-6); + font-family: + 'Apple Color Emoji', + 'Segoe UI Emoji', + NotoColorEmoji, + 'Noto Color Emoji', + 'Segoe UI Symbol', + Android Emoji, + EmojiSymbols; + font-size: 1.1em; + } + + .notion-renderer :where(img.notion-page-icon, svg.notion-page-icon) { + display: block; + max-width: 22px; + max-height: 22px; + border-radius: 3px; + object-fit: fill; + } + + .notion-renderer :where(.notion-icon) { + display: block; + width: 18px; + height: 18px; + color: var(--fg-color-icon); + } + + .notion-renderer :where(.notion-page-text) { + overflow: hidden; + margin: 4px 0; + border-bottom: 1px solid var(--fg-color-1); + font-weight: 500; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; + } + + .notion-renderer :where(.notion-page-text.not-found) { + border-bottom: 0; + font-weight: normal; + text-decoration: none; + } + + .notion-renderer :where(.notion-file) { + width: 100%; + margin: 4px 0; + } + + .notion-renderer :where(.notion-file a) { + display: block; + padding: 0.5rem 0.2rem 0.4rem; + border-radius: 3px; + color: var(--fg-color); + font-weight: 500; + line-height: 1.4rem; + text-decoration: none; + transition: background-color 120ms ease; + } + + .notion-renderer :where(.notion-file a:hover) { + background-color: var(--bg-color-0); + } + + .notion-renderer :where(.notion-file a img) { + width: 1.3rem; + height: 1.3rem; + vertical-align: sub; + } + + .notion-renderer :where(.notion-equation) { + width: 100%; + overflow-x: auto; + padding: 0.75rem 0; + text-align: center; + } + + .notion-renderer :where(.notion-code-frame) { + width: 100%; + } + + .notion-renderer :where(.notion-code-frame pre) { + margin: 0; + } + + .notion-renderer :where(.notion-code-frame code) { + border: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: inherit; + padding: 0 !important; + } + + @media (max-width: 640px) { + .notion-renderer :where(.notion-row) { + flex-direction: column; + } + + .notion-renderer :where(.notion-column) { + width: 100% !important; + } + + .notion-renderer :where(.notion-column + .notion-column) { + margin-left: 0; + } + + .notion-renderer :where(.notion-asset-wrapper-video iframe) { + height: 220px; + } + } } @utility small {