feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -1,19 +1,69 @@
import type {
Category as UiCategory,
ContentOverview,
ContentWindowHighlight,
FriendLink as UiFriendLink,
Post as UiPost,
PopularPostHighlight,
SiteSettings,
Tag as UiTag,
} from '../types';
const envApiBaseUrl = import.meta.env.PUBLIC_API_BASE_URL?.trim();
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api';
const PROD_DEFAULT_API_PORT = '5150';
export const API_BASE_URL =
envApiBaseUrl && envApiBaseUrl.length > 0
? envApiBaseUrl.replace(/\/$/, '')
: import.meta.env.DEV
? 'http://127.0.0.1:5150/api'
: 'https://init.cool/api';
function normalizeApiBaseUrl(value?: string | null) {
return value?.trim().replace(/\/$/, '') ?? '';
}
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
const runtimeProcess = (globalThis as typeof globalThis & {
process?: {
env?: Record<string, string | undefined>;
};
}).process;
return normalizeApiBaseUrl(runtimeProcess?.env?.[name]);
}
function toUrlLike(value: string | URL) {
return value instanceof URL ? value : new URL(value);
}
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
if (runtimePublicApiBaseUrl) {
return runtimePublicApiBaseUrl;
}
if (buildTimePublicApiBaseUrl) {
return buildTimePublicApiBaseUrl;
}
if (import.meta.env.DEV) {
return DEV_API_BASE_URL;
}
if (requestUrl) {
const { protocol, hostname } = toUrlLike(requestUrl);
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}/api`;
}
return DEV_API_BASE_URL;
}
export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
const runtimeInternalApiBaseUrl = getRuntimeEnv('INTERNAL_API_BASE_URL');
if (runtimeInternalApiBaseUrl) {
return runtimeInternalApiBaseUrl;
}
return resolvePublicApiBaseUrl(requestUrl);
}
export const API_BASE_URL = resolvePublicApiBaseUrl();
export interface ApiPost {
id: number;
@@ -27,6 +77,15 @@ export interface ApiPost {
image: string | null;
images: string[] | null;
pinned: boolean;
status: string | null;
visibility: 'public' | 'unlisted' | 'private' | null;
publish_at: string | null;
unpublish_at: string | null;
canonical_url: string | null;
noindex: boolean | null;
og_image: string | null;
redirect_from: string[] | null;
redirect_to: string | null;
created_at: string;
updated_at: string;
}
@@ -38,6 +97,9 @@ export interface Comment {
author: string | null;
email: string | null;
avatar: string | null;
ip_address: string | null;
user_agent: string | null;
referer: string | null;
content: string | null;
reply_to: string | null;
reply_to_comment_id: number | null;
@@ -59,6 +121,15 @@ export interface CreateCommentInput {
paragraphExcerpt?: string;
replyTo?: string | null;
replyToCommentId?: number | null;
captchaToken?: string;
captchaAnswer?: string;
website?: string;
}
export interface CommentCaptchaChallenge {
token: string;
question: string;
expires_in_seconds: number;
}
export interface ParagraphCommentSummary {
@@ -101,6 +172,35 @@ export interface CreateFriendLinkInput {
category?: string;
}
export interface PublicSubscriptionResponse {
ok: boolean;
subscription_id: number;
status: string;
requires_confirmation: boolean;
message: string;
}
export interface PublicManagedSubscription {
created_at: string;
updated_at: string;
id: number;
channel_type: string;
target: string;
display_name: string | null;
status: string;
filters: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
verified_at: string | null;
last_notified_at: string | null;
last_delivery_status: string | null;
manage_token: string | null;
}
export interface PublicSubscriptionManageResponse {
ok: boolean;
subscription: PublicManagedSubscription;
}
export interface ApiSiteSettings {
id: number;
site_name: string | null;
@@ -130,6 +230,19 @@ export interface ApiSiteSettings {
}> | null;
ai_enabled: boolean;
paragraph_comments_enabled: boolean;
seo_default_og_image: string | null;
seo_default_twitter_handle: string | null;
}
export interface ContentAnalyticsInput {
eventType: 'page_view' | 'read_progress' | 'read_complete';
path: string;
postSlug?: string;
sessionId?: string;
durationMs?: number;
progressPercent?: number;
metadata?: Record<string, unknown>;
referrer?: string;
}
export interface ApiHomePagePayload {
@@ -138,6 +251,42 @@ export interface ApiHomePagePayload {
tags: ApiTag[];
friend_links: ApiFriendLink[];
categories: ApiCategory[];
content_overview?: {
total_page_views: number;
page_views_last_24h: number;
page_views_last_7d: number;
total_read_completes: number;
read_completes_last_7d: number;
avg_read_progress_last_7d: number;
avg_read_duration_ms_last_7d: number | null;
};
popular_posts?: Array<{
slug: string;
title: string;
page_views: number;
read_completes: number;
avg_progress_percent: number;
avg_duration_ms: number | null;
}>;
content_ranges?: Array<{
key: string;
label: string;
days: number;
overview: {
page_views: number;
read_completes: number;
avg_read_progress: number;
avg_read_duration_ms: number | null;
};
popular_posts: Array<{
slug: string;
title: string;
page_views: number;
read_completes: number;
avg_progress_percent: number;
avg_duration_ms: number | null;
}>;
}>;
}
export interface AiSource {
@@ -249,6 +398,10 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
comments: {
paragraphsEnabled: true,
},
seo: {
defaultOgImage: undefined,
defaultTwitterHandle: undefined,
},
};
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
@@ -273,6 +426,15 @@ const normalizePost = (post: ApiPost): UiPost => ({
image: post.image ?? undefined,
images: post.images ?? undefined,
pinned: post.pinned,
status: post.status ?? undefined,
visibility: post.visibility ?? undefined,
publishAt: post.publish_at ?? undefined,
unpublishAt: post.unpublish_at ?? undefined,
canonicalUrl: post.canonical_url ?? undefined,
noindex: post.noindex ?? undefined,
ogImage: post.og_image ?? undefined,
redirectFrom: post.redirect_from ?? undefined,
redirectTo: post.redirect_to ?? undefined,
});
const normalizeTag = (tag: ApiTag): UiTag => ({
@@ -361,8 +523,127 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
comments: {
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
},
seo: {
defaultOgImage: settings.seo_default_og_image ?? undefined,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
},
});
const normalizeContentOverview = (
overview: ApiHomePagePayload['content_overview'] | undefined,
): ContentOverview => ({
totalPageViews: overview?.total_page_views ?? 0,
pageViewsLast24h: overview?.page_views_last_24h ?? 0,
pageViewsLast7d: overview?.page_views_last_7d ?? 0,
totalReadCompletes: overview?.total_read_completes ?? 0,
readCompletesLast7d: overview?.read_completes_last_7d ?? 0,
avgReadProgressLast7d: overview?.avg_read_progress_last_7d ?? 0,
avgReadDurationMsLast7d: overview?.avg_read_duration_ms_last_7d ?? undefined,
});
const CONTENT_WINDOW_META = [
{ key: '24h', label: '24h', days: 1 },
{ key: '7d', label: '7d', days: 7 },
{ key: '30d', label: '30d', days: 30 },
] as const;
const normalizePopularPost = (
item: {
slug: string;
title: string;
page_views: number;
read_completes: number;
avg_progress_percent: number;
avg_duration_ms: number | null;
},
postsBySlug: Map<string, UiPost>,
): PopularPostHighlight => ({
slug: item.slug,
title: item.title,
pageViews: item.page_views,
readCompletes: item.read_completes,
avgProgressPercent: item.avg_progress_percent,
avgDurationMs: item.avg_duration_ms ?? undefined,
post: postsBySlug.get(item.slug),
});
const normalizeContentRanges = (
ranges: ApiHomePagePayload['content_ranges'] | undefined,
overview: ApiHomePagePayload['content_overview'] | undefined,
popularPosts: ApiHomePagePayload['popular_posts'] | undefined,
postsBySlug: Map<string, UiPost>,
): ContentWindowHighlight[] => {
const normalizedRanges = new Map(
(ranges ?? []).map((item) => [
item.key,
{
key: item.key,
label: item.label,
days: item.days,
overview: {
pageViews: item.overview?.page_views ?? 0,
readCompletes: item.overview?.read_completes ?? 0,
avgReadProgress: item.overview?.avg_read_progress ?? 0,
avgReadDurationMs: item.overview?.avg_read_duration_ms ?? undefined,
},
popularPosts: (item.popular_posts ?? []).map((popularItem) =>
normalizePopularPost(popularItem, postsBySlug),
),
},
]),
);
return CONTENT_WINDOW_META.map((meta) => {
const existing = normalizedRanges.get(meta.key);
if (existing) {
return existing;
}
if (meta.key === '7d') {
return {
key: meta.key,
label: meta.label,
days: meta.days,
overview: {
pageViews: overview?.page_views_last_7d ?? 0,
readCompletes: overview?.read_completes_last_7d ?? 0,
avgReadProgress: overview?.avg_read_progress_last_7d ?? 0,
avgReadDurationMs: overview?.avg_read_duration_ms_last_7d ?? undefined,
},
popularPosts: (popularPosts ?? []).map((item) => normalizePopularPost(item, postsBySlug)),
};
}
if (meta.key === '24h') {
return {
key: meta.key,
label: meta.label,
days: meta.days,
overview: {
pageViews: overview?.page_views_last_24h ?? 0,
readCompletes: 0,
avgReadProgress: 0,
avgReadDurationMs: undefined,
},
popularPosts: [],
};
}
return {
key: meta.key,
label: meta.label,
days: meta.days,
overview: {
pageViews: 0,
readCompletes: 0,
avgReadProgress: 0,
avgReadDurationMs: undefined,
},
popularPosts: [],
};
});
};
class ApiClient {
private baseUrl: string;
@@ -424,6 +705,22 @@ class ApiClient {
return normalizePost((await response.json()) as ApiPost);
}
async recordContentEvent(input: ContentAnalyticsInput): Promise<void> {
await this.fetch<{ recorded: boolean }>('/analytics/content', {
method: 'POST',
body: JSON.stringify({
event_type: input.eventType,
path: input.path,
post_slug: input.postSlug,
session_id: input.sessionId,
duration_ms: input.durationMs,
progress_percent: input.progressPercent,
metadata: input.metadata,
referrer: input.referrer,
}),
})
}
async getComments(
postSlug: string,
options?: {
@@ -463,10 +760,17 @@ class ApiClient {
paragraphExcerpt: comment.paragraphExcerpt,
replyTo: comment.replyTo,
replyToCommentId: comment.replyToCommentId,
captchaToken: comment.captchaToken,
captchaAnswer: comment.captchaAnswer,
website: comment.website,
}),
});
}
async getCommentCaptcha(): Promise<CommentCaptchaChallenge> {
return this.fetch<CommentCaptchaChallenge>('/comments/captcha');
}
async getReviews(): Promise<Review[]> {
return this.fetch<Review[]>('/reviews');
}
@@ -491,6 +795,54 @@ class ApiClient {
});
}
async subscribe(input: { email: string; displayName?: string; source?: string }): Promise<PublicSubscriptionResponse> {
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
method: 'POST',
body: JSON.stringify({
email: input.email,
displayName: input.displayName,
source: input.source,
}),
});
}
async confirmSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/confirm', {
method: 'POST',
body: JSON.stringify({ token }),
});
}
async getManagedSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>(
`/subscriptions/manage?token=${encodeURIComponent(token)}`,
);
}
async updateManagedSubscription(input: {
token: string;
displayName?: string | null;
status?: string | null;
filters?: Record<string, unknown> | null;
}): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/manage', {
method: 'PATCH',
body: JSON.stringify({
token: input.token,
displayName: input.displayName,
status: input.status,
filters: input.filters,
}),
});
}
async unsubscribeSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/unsubscribe', {
method: 'POST',
body: JSON.stringify({ token }),
});
}
async getRawTags(): Promise<ApiTag[]> {
return this.fetch<ApiTag[]>('/tags');
}
@@ -515,15 +867,31 @@ class ApiClient {
tags: UiTag[];
friendLinks: AppFriendLink[];
categories: UiCategory[];
contentOverview: ContentOverview;
contentRanges: ContentWindowHighlight[];
popularPosts: PopularPostHighlight[];
}> {
const payload = await this.fetch<ApiHomePagePayload>('/site_settings/home');
const posts = (payload.posts ?? []).map(normalizePost);
const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
const popularPosts = (payload.popular_posts ?? []).map((item) =>
normalizePopularPost(item, postsBySlug),
);
return {
siteSettings: normalizeSiteSettings(payload.site_settings),
posts: payload.posts.map(normalizePost),
tags: payload.tags.map(normalizeTag),
friendLinks: payload.friend_links.map(normalizeFriendLink),
categories: payload.categories.map(normalizeCategory),
posts,
tags: (payload.tags ?? []).map(normalizeTag),
friendLinks: (payload.friend_links ?? []).map(normalizeFriendLink),
categories: (payload.categories ?? []).map(normalizeCategory),
contentOverview: normalizeContentOverview(payload.content_overview),
contentRanges: normalizeContentRanges(
payload.content_ranges,
payload.content_overview,
payload.popular_posts,
postsBySlug,
),
popularPosts,
};
}
@@ -579,5 +947,9 @@ class ApiClient {
}
}
export const api = new ApiClient(API_BASE_URL);
export function createApiClient(options?: { baseUrl?: string; requestUrl?: string | URL }) {
return new ApiClient(options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl));
}
export const api = createApiClient();
export const apiClient = api;

View File

@@ -127,12 +127,30 @@ export const messages = {
about: '关于我',
techStack: '技术栈',
systemStatus: '系统状态',
hotNow: '最近热门内容',
hotNowDescription: '基于 24h / 7d / 30d 的页面访问与阅读完成信号生成,可切换窗口查看最有反馈的内容。',
hotNowEmpty: '当前筛选条件下还没有热门内容信号。',
sortByViews: '最多浏览',
sortByCompletes: '完读最多',
sortByDepth: '阅读最深',
readingSignals: '阅读信号',
readingSignalsDescription: '以下统计面板会跟随所选窗口切换,展示全站内容消费表现。',
views: '浏览',
completes: '完读',
avgProgress: '平均进度',
avgDuration: '平均时长',
totalViews: '累计浏览',
totalCompletes: '累计完读',
statsWindow: '统计窗口:最近 7 天',
statsWindowLabel: '统计窗口:{label}',
promptWelcome: 'pwd',
promptDiscoverDefault: "find ./posts -type f | sort",
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
promptPostsDefault: "find ./posts -type f | head -n {count}",
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
promptPopular: "awk 'NR<=6 {print}' ./analytics/popular-posts.log",
promptPopularRange: "awk 'NR<=6 {print}' ./analytics/popular-posts.log --window={label}",
promptFriends: "find ./links -maxdepth 1 -type f | sort",
promptAbout: "sed -n '1,80p' ~/profile.md",
},
@@ -148,6 +166,25 @@ export const messages = {
previous: '上一页',
next: '下一页',
},
searchPage: {
pageTitle: '站内搜索',
title: '搜索结果',
intro: '独立搜索页会保留搜索词,并支持按类型、分类、标签继续缩小范围。',
promptIdle: "printf 'search query required\\n'",
promptQuery: 'grep -Rin "{query}" ./posts',
queryLabel: '当前查询',
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
resultSummary: '找到 {count} 条结果',
filteredSummary: '筛选后剩余 {count} 条结果',
filtersTitle: '二次筛选',
allCategories: '全部分类',
allTags: '全部标签',
emptyQueryTitle: '先输入关键词',
emptyQueryDescription: '可以直接使用顶部搜索框,或在 URL 中传入 ?q= 进入搜索结果页。',
emptyTitle: '没有匹配结果',
emptyDescription: '可以切换分类 / 标签,或换一个关键词重新搜索。',
askFallback: '改去 AI 问答',
},
article: {
backToArticles: '返回文章索引',
documentSession: '文档会话',
@@ -158,6 +195,9 @@ export const messages = {
title: '相关文章',
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
linked: '{count} 条关联',
hotKicker: '热门延伸',
hotTitle: '同类热门文章',
hotDescription: '结合最近 7 天的访问与阅读数据,优先推荐同分类或共享标签的高反馈内容。',
},
comments: {
title: '评论终端',
@@ -528,12 +568,30 @@ export const messages = {
about: 'About',
techStack: 'Tech stack',
systemStatus: 'System status',
hotNow: 'Hot now',
hotNowDescription: 'Generated from 24h / 7d / 30d page-view and completion signals so visitors can switch windows and spot the strongest feedback loops quickly.',
hotNowEmpty: 'No hot content matched the current filters yet.',
sortByViews: 'Most viewed',
sortByCompletes: 'Most completed',
sortByDepth: 'Deepest reads',
readingSignals: 'Reading signals',
readingSignalsDescription: 'These stats switch with the selected window to summarize site-wide content consumption.',
views: 'Views',
completes: 'Completes',
avgProgress: 'Avg progress',
avgDuration: 'Avg duration',
totalViews: 'Total views',
totalCompletes: 'Total completes',
statsWindow: 'Window: last 7 days',
statsWindowLabel: 'Window: {label}',
promptWelcome: 'pwd',
promptDiscoverDefault: "find ./posts -type f | sort",
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
promptPostsDefault: "find ./posts -type f | head -n {count}",
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
promptPopular: "awk 'NR<=6 {print}' ./analytics/popular-posts.log",
promptPopularRange: "awk 'NR<=6 {print}' ./analytics/popular-posts.log --window={label}",
promptFriends: "find ./links -maxdepth 1 -type f | sort",
promptAbout: "sed -n '1,80p' ~/profile.md",
},
@@ -549,6 +607,25 @@ export const messages = {
previous: 'Prev',
next: 'Next',
},
searchPage: {
pageTitle: 'Site search',
title: 'Search results',
intro: 'The dedicated search page keeps the query visible and lets visitors narrow results by type, category, and tag.',
promptIdle: "printf 'search query required\\n'",
promptQuery: 'grep -Rin "{query}" ./posts',
queryLabel: 'Current query',
searchTips: 'Search uses the site index first and also applies synonyms plus lightweight typo correction automatically.',
resultSummary: 'Found {count} results',
filteredSummary: '{count} results after filters',
filtersTitle: 'Refine results',
allCategories: 'All categories',
allTags: 'All tags',
emptyQueryTitle: 'Enter a keyword first',
emptyQueryDescription: 'Use the header search box or open this page with a ?q= query string.',
emptyTitle: 'No matching results',
emptyDescription: 'Try switching categories / tags or search again with another keyword.',
askFallback: 'Ask the AI instead',
},
article: {
backToArticles: 'Back to article index',
documentSession: 'Document session',
@@ -559,6 +636,9 @@ export const messages = {
title: 'Related Posts',
description: 'More nearby reading paths based on the current category and shared tags.',
linked: '{count} linked',
hotKicker: 'Hot follow-up',
hotTitle: 'Popular related posts',
hotDescription: 'Uses the last 7 days of visit and reading signals to recommend the strongest-performing posts from the same category or shared tags.',
},
comments: {
title: 'Comment Terminal',

135
frontend/src/lib/image.ts Normal file
View File

@@ -0,0 +1,135 @@
const OPTIMIZED_IMAGE_ENDPOINT = '/_img';
function trimToList(value: string | undefined | null) {
return String(value || '')
.split(',')
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
}
function normalizeSrc(value: string) {
return value.trim();
}
function getPathname(value: string) {
try {
return new URL(value, 'http://localhost').pathname;
} catch {
return value;
}
}
export function getImageExtension(src: string) {
const pathname = getPathname(normalizeSrc(src)).toLowerCase();
const matched = pathname.match(/\.([a-z0-9]+)$/i);
return matched?.[1] ?? '';
}
export function shouldOptimizeImage(src: string) {
const normalized = normalizeSrc(src);
if (!normalized || normalized.startsWith('data:') || normalized.startsWith('blob:')) {
return false;
}
return !['svg', 'gif'].includes(getImageExtension(normalized));
}
export function isAllowedRemoteImageHost(
src: string,
currentOrigin: string,
envHosts?: string,
) {
try {
const sourceUrl = new URL(normalizeSrc(src), currentOrigin);
const currentHost = new URL(currentOrigin).host.toLowerCase();
const allowedHosts = new Set([currentHost, ...trimToList(envHosts)]);
return allowedHosts.has(sourceUrl.host.toLowerCase()) || allowedHosts.has(sourceUrl.hostname.toLowerCase());
} catch {
return false;
}
}
export function canOptimizeImageSource(
src: string,
currentOrigin: string,
envHosts?: string,
) {
const normalized = normalizeSrc(src);
if (!shouldOptimizeImage(normalized)) {
return false;
}
if (normalized.startsWith('/')) {
return true;
}
try {
const sourceUrl = new URL(normalized);
return isAllowedRemoteImageHost(sourceUrl.toString(), currentOrigin, envHosts);
} catch {
return false;
}
}
export function getFallbackImageFormat(src: string): 'jpeg' | 'png' {
return getImageExtension(src) === 'png' ? 'png' : 'jpeg';
}
export function getResponsiveWidths(widths?: number[]) {
const defaults = [480, 768, 1024, 1440, 1920];
const source = Array.isArray(widths) && widths.length ? widths : defaults;
return Array.from(
new Set(
source
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.round(value)),
),
).sort((left, right) => left - right);
}
export function buildOptimizedImageUrl(
src: string,
options?: {
width?: number;
format?: 'avif' | 'webp' | 'jpeg' | 'png';
quality?: number;
},
) {
const params = new URLSearchParams({
src: normalizeSrc(src),
});
if (options?.width) {
params.set('w', String(Math.round(options.width)));
}
if (options?.format) {
params.set('f', options.format);
}
if (options?.quality) {
params.set('q', String(Math.round(options.quality)));
}
return `${OPTIMIZED_IMAGE_ENDPOINT}?${params.toString()}`;
}
export function buildOptimizedSrcSet(
src: string,
widths: number[],
format: 'avif' | 'webp' | 'jpeg' | 'png',
quality?: number,
) {
return getResponsiveWidths(widths)
.map(
(width) =>
`${buildOptimizedImageUrl(src, {
width,
format,
quality,
})} ${width}w`,
)
.join(', ');
}

View File

@@ -14,6 +14,15 @@ export interface Post {
code?: string;
language?: string;
pinned?: boolean;
status?: string;
visibility?: 'public' | 'unlisted' | 'private' | string;
publishAt?: string;
unpublishAt?: string;
canonicalUrl?: string;
noindex?: boolean;
ogImage?: string;
redirectFrom?: string[];
redirectTo?: string;
}
export interface Category {
@@ -68,6 +77,10 @@ export interface SiteSettings {
comments: {
paragraphsEnabled: boolean;
};
seo: {
defaultOgImage?: string;
defaultTwitterHandle?: string;
};
}
export interface MusicTrack {
@@ -103,3 +116,38 @@ export interface TechStackItem {
icon?: string;
level?: string;
}
export interface ContentOverview {
totalPageViews: number;
pageViewsLast24h: number;
pageViewsLast7d: number;
totalReadCompletes: number;
readCompletesLast7d: number;
avgReadProgressLast7d: number;
avgReadDurationMsLast7d?: number;
}
export interface PopularPostHighlight {
slug: string;
title: string;
pageViews: number;
readCompletes: number;
avgProgressPercent: number;
avgDurationMs?: number;
post?: Post;
}
export interface ContentWindowOverview {
pageViews: number;
readCompletes: number;
avgReadProgress: number;
avgReadDurationMs?: number;
}
export interface ContentWindowHighlight {
key: string;
label: string;
days: number;
overview: ContentWindowOverview;
popularPosts: PopularPostHighlight[];
}