diff --git a/public/life/giffgaff-esim-white-card/image(1).jpg b/public/life/giffgaff-esim-white-card/image(1).jpg new file mode 100644 index 0000000..7947d03 Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(1).jpg differ diff --git a/public/life/giffgaff-esim-white-card/image(10).jpg b/public/life/giffgaff-esim-white-card/image(10).jpg new file mode 100644 index 0000000..726db54 Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(10).jpg differ diff --git a/public/life/giffgaff-esim-white-card/image(11).jpg b/public/life/giffgaff-esim-white-card/image(11).jpg new file mode 100644 index 0000000..b616438 Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(11).jpg differ diff --git a/public/life/giffgaff-esim-white-card/image(12).webp b/public/life/giffgaff-esim-white-card/image(12).webp new file mode 100644 index 0000000..de6162e Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(12).webp differ diff --git a/public/life/giffgaff-esim-white-card/image(16).jpg b/public/life/giffgaff-esim-white-card/image(16).jpg new file mode 100644 index 0000000..18dd707 Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(16).jpg differ diff --git a/public/life/giffgaff-esim-white-card/image(18).jpg b/public/life/giffgaff-esim-white-card/image(18).jpg new file mode 100644 index 0000000..efec96c Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(18).jpg differ diff --git a/public/life/giffgaff-esim-white-card/image(2).jpg b/public/life/giffgaff-esim-white-card/image(2).jpg new file mode 100644 index 0000000..5732567 Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(2).jpg differ diff --git a/public/life/giffgaff-esim-white-card/image(20).jpg b/public/life/giffgaff-esim-white-card/image(20).jpg new file mode 100644 index 0000000..1603c41 Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image(20).jpg differ diff --git a/public/life/giffgaff-esim-white-card/image.jpg b/public/life/giffgaff-esim-white-card/image.jpg new file mode 100644 index 0000000..98ffbfa Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image.jpg differ diff --git a/public/life/giffgaff-esim-white-card/image.webp b/public/life/giffgaff-esim-white-card/image.webp new file mode 100644 index 0000000..cb258b2 Binary files /dev/null and b/public/life/giffgaff-esim-white-card/image.webp differ diff --git a/src/app/globals.css b/src/app/globals.css index 4228ced..a14bc1e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -34,8 +34,8 @@ body { } ::selection { - background: #22d3ee; - color: #05050b; + background: #b9c6a3; + color: #151515; } a { diff --git a/src/components/life-article-page.tsx b/src/components/life-article-page.tsx index 319489a..7f46ba8 100644 --- a/src/components/life-article-page.tsx +++ b/src/components/life-article-page.tsx @@ -14,6 +14,65 @@ type LifeArticlePageProps = { nextPost?: LifePost; }; +const paragraphLinkPattern = /\[([^\]]+)\]\(([^)\s]+)\)/g; + +function isAllowedParagraphHref(href: string) { + return ( + href.startsWith("/") || + href.startsWith("#") || + href.startsWith("https://") || + href.startsWith("http://") || + href.startsWith("mailto:") + ); +} + +function renderParagraphText(text: string) { + const parts = []; + let lastIndex = 0; + + for (const match of text.matchAll(paragraphLinkPattern)) { + const [rawText, label, href] = match; + const matchIndex = match.index ?? 0; + + if (matchIndex > lastIndex) { + parts.push(text.slice(lastIndex, matchIndex)); + } + + if (isAllowedParagraphHref(href)) { + const linkClassName = + "text-[#151515]/82 underline decoration-[#8b967a]/45 decoration-1 underline-offset-[5px] transition hover:text-[#5f6f52] hover:decoration-[#5f6f52]"; + + parts.push( + href.startsWith("/") || href.startsWith("#") ? ( + + {label} + + ) : ( + + {label} + + ), + ); + } else { + parts.push(rawText); + } + + lastIndex = matchIndex + rawText.length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; +} + export function LifeArticlePage({ post, prevPost, nextPost }: LifeArticlePageProps) { const reduceMotion = useReducedMotion(); const distance = reduceMotion ? 0 : 24; @@ -119,28 +178,51 @@ export function LifeArticlePage({ post, prevPost, nextPost }: LifeArticlePagePro return (

- {block.text} + {renderParagraphText(block.text)}

); } + const showOriginalImage = block.display === "original" && block.width && block.height; + const originalImageAlignClass = + block.align === "center" + ? "mx-auto" + : block.align === "right" + ? "ml-auto" + : "mr-auto"; + return ( -
+ {showOriginalImage ? ( {block.alt} -
+ ) : ( +
+ {block.alt} +
+ )} {block.caption ? (
{block.caption} diff --git a/src/content/life/README.md b/src/content/life/README.md new file mode 100644 index 0000000..3214154 --- /dev/null +++ b/src/content/life/README.md @@ -0,0 +1,128 @@ +# 日常分享内容说明 + +这个目录放 `/life` 下的日常分享文章。每篇文章是一个 `LifePost` 对象,字段定义在 `types.ts`。 + +## 文章字段 + +```ts +export const demoPost = { + slug: "demo-post", + date: "2026.06.26", + title: "文章标题", + description: "列表页和文章头部显示的简介。", + content: [ + { + type: "paragraph", + text: "一段正文。", + }, + ], +} satisfies LifePost; +``` + +- `slug`: 文章地址的一部分,例如 `demo-post` 会生成 `/life/demo-post`。 +- `date`: 文章日期,格式用 `YYYY.MM.DD`。列表页会按这个字段倒序排序。 +- `title`: 文章标题。 +- `description`: 文章简介,会显示在列表页、精选文章和文章头部。 +- `content`: 文章正文块数组。现在支持段落和图片,可以按阅读顺序穿插。 + +## 段落块 + +```ts +{ + type: "paragraph", + text: "这里是一段正文。", +} +``` + +- `type`: 固定写 `"paragraph"`。 +- `text`: 正文内容。一个对象对应一个段落。 + +如果某一段内部需要手动换行,可以在 `text` 里写 `\n`: + +```ts +{ + type: "paragraph", + text: "第一行\n第二行\n第三行", +} +``` + +如果正文里需要链接,可以用 Markdown 风格: + +```ts +{ + type: "paragraph", + text: "参考这篇文章:[Giffgaff eSIM 直装版](https://simonmy.com/posts/giffgaff-esim-mod-apk.html)", +} +``` + +支持站内路径、锚点、`http(s)` 和 `mailto:`。外部链接会新开标签。 + +## 图片块 + +```ts +{ + type: "image", + src: "/life/demo/image.jpg", + alt: "图片说明", +} +``` + +- `type`: 固定写 `"image"`。 +- `src`: 图片路径。放在 `public` 里的图片,路径从 `/` 开始写。 +- `alt`: 图片替代文本,用于无障碍和图片加载失败时展示。 +- `caption`: 可选。图片下方的图注,不想显示图注就不写。 +- `display`: 可选。图片展示方式,支持 `"cover"` 和 `"original"`。 +- `align`: 可选。原图模式下的对齐方式,支持 `"left"`、`"center"`、`"right"`,默认左对齐。 +- `width` / `height`: 可选。原图模式建议填写原始像素尺寸,避免页面加载时跳动。 + +## 图片展示方式 + +### 封面模式 + +```ts +{ + type: "image", + src: "/life/demo/cover.jpg", + alt: "横向封面图", + display: "cover", +} +``` + +`cover` 会把图片放进固定比例的展示框里,适合横向照片、封面图、氛围图。图片会铺满容器,必要时会被裁切。 + +不写 `display` 时,默认也按封面模式显示,兼容旧文章。 + +### 原图模式 + +```ts +{ + type: "image", + src: "/life/demo/screenshot.jpg", + alt: "手机截图", + display: "original", + align: "left", + width: 278, + height: 472, +} +``` + +`original` 会按图片原始比例和尺寸展示,不加背景框、边框和阴影。图片超过正文宽度时才会缩小。适合手机截图、软件界面截图、聊天记录截图这类不想被裁切的图片。 + +`align` 只在原图模式下生效: + +- `"left"`: 左对齐,也是默认值。 +- `"center"`: 居中。 +- `"right"`: 右对齐。 + +## 封面图规则 + +列表页和精选文章会把文章里的第一张图片当封面图。封面区域始终按封面方式显示,所以即使正文里这张图是 `display: "original"`,列表封面也会裁切展示。 + +如果某篇文章没有图片,列表页不会显示图片卡槽,只显示文字内容。 + +## 常用建议 + +- 手机截图、步骤截图:用 `display: "original"`,并填写 `width` 和 `height`。 +- 横向照片、活动图、封面图:用 `display: "cover"` 或不写 `display`。 +- 不想显示图注:不要写 `caption`。 +- 新增文章后,把文章对象加到 `posts.ts` 的 `allLifePosts` 数组里即可,列表顺序会自动按日期排序。 diff --git a/src/content/life/articles/giffgaff-esim-white-card.ts b/src/content/life/articles/giffgaff-esim-white-card.ts new file mode 100644 index 0000000..1cf5b38 --- /dev/null +++ b/src/content/life/articles/giffgaff-esim-white-card.ts @@ -0,0 +1,134 @@ +import type { LifePost } from "../types"; + +export const giffgaffEsimWhiteCard = { + slug: "giffgaff-esim-white-card", + date: "2026.06.03", + title: "小白卡写入 giffgaff 的流程", + description: "记录一次把 giffgaff eSIM 写入小白卡的流程,从准备材料到成功出信号。", + content: [ + { + type: "paragraph", + text: "准备工作:\n一台安卓手机\n一张能支付外币的visa或者万事达\n一张 euicc 小白卡,pdd购买\n", + }, + { + type: "paragraph", + text: "第一步是把小白卡插进安卓手机卡槽,然后安装 EasyEUICC。\n[链接 https://gitea.angry.im/PeterCxy/OpenEUICC/releases](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)\n 安装时如果系统或者安全软件拦截,按实际提示放行就行。打开后先做一次兼容性检查,检查完成之后把 app 从后台关掉再重新打开,右下角展示+则正常。", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image.jpg", + alt: "EasyEUICC 安装界面", + display: "original", + width: 157, + height: 201, + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(1).jpg", + alt: "EasyEUICC 主界面", + display: "original", + width: 288, + height: 376, + }, + { + type: "paragraph", + text: "第二步是去拿 giffgaff 的 eSIM 信息。需要使用改造版 giffgaff app,打开后先允许需要的权限,再按页面流程接受 cookies、创建新用户、填写邮箱、接收验证码并设置密码。[改造版获取地址 https://t.me/simonlabs](https://t.me/simonlabs)", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(2).jpg", + alt: "giffgaff app 初始界面", + display: "original", + width: 398, + height: 477, + }, + { + type: "paragraph", + text: "这里遇到过一个比较烦的小坑:填写邮箱后如果手机默认浏览器是系统自带浏览器,可能会卡在登录或者验证环节里反复跳转。我当时的解决办法是装一个第三方浏览器,并在系统设置里把它设为默认浏览器,再回到 giffgaff 流程继续。", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image.webp", + alt: "giffgaff 邮箱填写界面", + display: "original", + width: 339, + height: 512, + }, + { + type: "paragraph", + text: "账号建好后会进入giffgaff首页,选择eSIM这个Tab, 点击Choose your plan, 滑动到最下方选择I don't want a plan,选择充值最少的10英镑", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(10).jpg", + alt: "giffgaff 套餐确认页面", + display: "original", + width: 337, + height: 485, + }, + { + type: "paragraph", + text: "付款前会让你补一段个人信息和银行卡信息。直接让ai生成一个真实的英国街道地址,填完之后继续添加银行卡并付款。", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(11).jpg", + alt: "giffgaff eSIM 申请成功页面", + display: "original", + width: 331, + height: 211, + }, + { + type: "paragraph", + text: "拿到 eSIM 之后,继续点 install eSIM。这个时候页面会弹出一段用于安装 eSIM 的信息,重点是把弹窗里那段内容复制并保存好,因为后面写入小白卡要用。", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(12).webp", + alt: "giffgaff eSIM 安装信息弹窗", + display: "original", + width: 335, + height: 439, + }, + { + type: "paragraph", + text: "第三步回到 EasyEUICC 写卡。打开 EasyEUICC 后先从右上角菜单再做一次兼容性检查,确认没问题后进入添加流程,点击右下角的加号,选择手动输入。", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(16).jpg", + alt: "EasyEUICC 添加 eSIM 页面", + display: "original", + width: 340, + height: 541, + }, + { + type: "paragraph", + text: "手动输入时,刚才复制的那段信息要拆开看:第一个 $ 到第二个 $ 中间那段一般是服务器地址,第二个 $ 后面的部分就是激活码。把对应内容填进去后点击下一步,EasyEUICC 会开始下载 profile。", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(18).jpg", + alt: "EasyEUICC 手动输入 eSIM 信息", + display: "original", + width: 312, + height: 407, + }, + { + type: "paragraph", + text: "下载完成后,在 EasyEUICC 里点右上角菜单,选择启用。等待一会儿如果能出信号,基本就说明写卡成功了,大概要几分钟时间,收到了 giffgaff 的短信,流程算是完整跑通。", + }, + { + type: "image", + src: "/life/giffgaff-esim-white-card/image(20).jpg", + alt: "giffgaff 短信确认", + display: "original", + width: 278, + height: 472, + }, + { + type: "paragraph", + text: "注意事项就是我们选择的是最便宜的套餐,没有月租,但是要每半年进行一次余额变动,不然会被停掉,所以每半年发送一次短信即可,扣费0.3英镑,不被封号的情况下,10英镑已经够用十几年了,记得定好闹钟或者每年五一,十一,过年都发送一次短信保号", + }, + ], +} satisfies LifePost; diff --git a/src/content/life/content.test-d.ts b/src/content/life/content.test-d.ts index 387a695..5c6ba63 100644 --- a/src/content/life/content.test-d.ts +++ b/src/content/life/content.test-d.ts @@ -1,5 +1,5 @@ -import { nikoMajorChampion } from "./articles/niko-major-champion"; import { countLifePostParagraphs, getLifePostImages } from "./content"; +import { lifePosts } from "./posts"; import type { LifeContentBlock, LifePostImage } from "./types"; const inlineContent: LifeContentBlock[] = [ @@ -9,22 +9,30 @@ const inlineContent: LifeContentBlock[] = [ src: "/life/demo/step.jpg", alt: "Demo step", caption: "A demo image inside the article.", + display: "original", + align: "center", + width: 320, + height: 240, }, { type: "paragraph", text: "second paragraph" }, ]; -const images: LifePostImage[] = getLifePostImages(nikoMajorChampion); -const paragraphCount: number = countLifePostParagraphs(nikoMajorChampion); +for (const post of lifePosts) { + const images: LifePostImage[] = getLifePostImages(post); + const paragraphCount: number = countLifePostParagraphs(post); + + void images; + void paragraphCount; +} + const inlineImages: LifePostImage[] = getLifePostImages({ - ...nikoMajorChampion, + ...lifePosts[0], content: inlineContent, }); const inlineParagraphCount: number = countLifePostParagraphs({ - ...nikoMajorChampion, + ...lifePosts[0], content: inlineContent, }); -void images; -void paragraphCount; void inlineImages; void inlineParagraphCount; diff --git a/src/content/life/content.ts b/src/content/life/content.ts index a0eb5b8..500c53a 100644 --- a/src/content/life/content.ts +++ b/src/content/life/content.ts @@ -3,7 +3,15 @@ import type { LifePost, LifePostImage } from "./types"; export function getLifePostImages(post: LifePost): LifePostImage[] { return post.content .filter((block) => block.type === "image") - .map(({ src, alt, caption }) => ({ src, alt, caption })); + .map(({ src, alt, caption, display, align, width, height }) => ({ + src, + alt, + caption, + display, + align, + width, + height, + })); } export function getLifePostCoverImage(post: LifePost): LifePostImage | undefined { diff --git a/src/content/life/posts.ts b/src/content/life/posts.ts index 0c21659..951df02 100644 --- a/src/content/life/posts.ts +++ b/src/content/life/posts.ts @@ -1,7 +1,16 @@ +import { giffgaffEsimWhiteCard } from "./articles/giffgaff-esim-white-card"; import { nikoMajorChampion } from "./articles/niko-major-champion"; import type { LifePost } from "./types"; -export const lifePosts = [nikoMajorChampion] as const satisfies readonly LifePost[]; +const allLifePosts = [giffgaffEsimWhiteCard, nikoMajorChampion] as const satisfies readonly LifePost[]; + +function toLifePostTimestamp(date: string) { + return Date.parse(date.replace(/\./g, "-")); +} + +export const lifePosts = [...allLifePosts].sort( + (postA, postB) => toLifePostTimestamp(postB.date) - toLifePostTimestamp(postA.date), +) as readonly LifePost[]; export function getLifePost(slug: string) { return lifePosts.find((post) => post.slug === slug); diff --git a/src/content/life/types.ts b/src/content/life/types.ts index 24588a0..740cb07 100644 --- a/src/content/life/types.ts +++ b/src/content/life/types.ts @@ -2,6 +2,10 @@ export type LifePostImage = { src: string; alt: string; caption?: string; + display?: "cover" | "original"; + align?: "left" | "center" | "right"; + width?: number; + height?: number; }; export type LifeContentBlock =