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.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 =