添加小白卡写入giffgaff分享
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -34,8 +34,8 @@ body {
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #22d3ee;
|
||||
color: #05050b;
|
||||
background: #b9c6a3;
|
||||
color: #151515;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -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("#") ? (
|
||||
<Link key={`link-${matchIndex}`} href={href} className={linkClassName}>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
key={`link-${matchIndex}`}
|
||||
href={href}
|
||||
className={linkClassName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
),
|
||||
);
|
||||
} 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,19 +178,41 @@ export function LifeArticlePage({ post, prevPost, nextPost }: LifeArticlePagePro
|
||||
return (
|
||||
<p
|
||||
key={`paragraph-${index}`}
|
||||
className="text-base leading-8 text-[#151515]/75 sm:text-lg sm:leading-9"
|
||||
className="whitespace-pre-line text-base leading-8 text-[#151515]/75 sm:text-lg sm:leading-9"
|
||||
>
|
||||
{block.text}
|
||||
{renderParagraphText(block.text)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const showOriginalImage = block.display === "original" && block.width && block.height;
|
||||
const originalImageAlignClass =
|
||||
block.align === "center"
|
||||
? "mx-auto"
|
||||
: block.align === "right"
|
||||
? "ml-auto"
|
||||
: "mr-auto";
|
||||
|
||||
return (
|
||||
<motion.figure
|
||||
key={`image-${index}`}
|
||||
variants={scaleReveal}
|
||||
className="overflow-hidden rounded-[1.5rem] border border-[#151515]/8 bg-white shadow-xl shadow-[#5f6f52]/8"
|
||||
className={
|
||||
showOriginalImage
|
||||
? "w-full"
|
||||
: "overflow-hidden rounded-[1.5rem] border border-[#151515]/8 bg-white shadow-xl shadow-[#5f6f52]/8"
|
||||
}
|
||||
>
|
||||
{showOriginalImage ? (
|
||||
<Image
|
||||
src={block.src}
|
||||
alt={block.alt}
|
||||
width={block.width}
|
||||
height={block.height}
|
||||
sizes="(max-width: 768px) 100vw, 896px"
|
||||
className={`${originalImageAlignClass} h-auto max-w-full rounded-[1rem]`}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative aspect-[16/9] w-full overflow-hidden bg-[#e7e8df]">
|
||||
<Image
|
||||
src={block.src}
|
||||
@@ -141,6 +222,7 @@ export function LifeArticlePage({ post, prevPost, nextPost }: LifeArticlePagePro
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{block.caption ? (
|
||||
<figcaption className="px-5 py-4 text-sm leading-6 text-[#151515]/55 sm:px-6">
|
||||
{block.caption}
|
||||
|
||||
@@ -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` 数组里即可,列表顺序会自动按日期排序。
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||