gengxin
@@ -2,4 +2,6 @@ node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
*.log
|
||||
.env.local
|
||||
.env.local
|
||||
.tmp/
|
||||
gzhh/
|
||||
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / ".tmp" / "gzhh-images"
|
||||
RAW = BASE / "raw"
|
||||
MANIFEST = BASE / "manifest.json"
|
||||
AUDIT = BASE / "audit.json"
|
||||
SHEETS = BASE / "contact-sheets"
|
||||
|
||||
CELL_WIDTH = 280
|
||||
CELL_HEIGHT = 220
|
||||
COLS = 5
|
||||
ROWS = 4
|
||||
PER_SHEET = COLS * ROWS
|
||||
|
||||
|
||||
def font(size: int):
|
||||
paths = [
|
||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
||||
]
|
||||
for path in paths:
|
||||
if path.exists():
|
||||
return ImageFont.truetype(str(path), size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
small_font = font(14)
|
||||
label_font = font(16)
|
||||
title_font = font(22)
|
||||
|
||||
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
unique_entries = []
|
||||
seen_files = set()
|
||||
|
||||
for entry in manifest:
|
||||
file_name = entry.get("file")
|
||||
if entry.get("status") != "ok" or not file_name or file_name in seen_files:
|
||||
continue
|
||||
seen_files.add(file_name)
|
||||
image_path = RAW / file_name
|
||||
record = {
|
||||
"id": len(unique_entries) + 1,
|
||||
"file": file_name,
|
||||
"path": image_path.relative_to(ROOT).as_posix(),
|
||||
"sources": entry.get("sources", []),
|
||||
"alts": entry.get("alts", []),
|
||||
"bytes": entry.get("bytes", 0),
|
||||
"hash": entry.get("hash", ""),
|
||||
}
|
||||
try:
|
||||
with Image.open(image_path) as image:
|
||||
record["width"], record["height"] = image.size
|
||||
record["format"] = image.format
|
||||
preview = image.convert("RGB")
|
||||
preview.thumbnail((128, 128))
|
||||
record["entropy"] = round(preview.convert("L").entropy(), 3)
|
||||
record["candidate"] = (
|
||||
record["width"] * record["height"] >= 200_000
|
||||
and min(record["width"], record["height"]) >= 280
|
||||
and 0.32 <= record["width"] / record["height"] <= 3.2
|
||||
and record["entropy"] >= 3.2
|
||||
)
|
||||
except Exception as error:
|
||||
record["error"] = str(error)
|
||||
record["candidate"] = False
|
||||
unique_entries.append(record)
|
||||
|
||||
AUDIT.write_text(json.dumps(unique_entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
SHEETS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
candidates = [item for item in unique_entries if item.get("candidate")]
|
||||
|
||||
for sheet_index in range(math.ceil(len(candidates) / PER_SHEET)):
|
||||
page_items = candidates[sheet_index * PER_SHEET : (sheet_index + 1) * PER_SHEET]
|
||||
sheet = Image.new("RGB", (CELL_WIDTH * COLS, 48 + CELL_HEIGHT * ROWS), "#f7f3e8")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
draw.text(
|
||||
(18, 10),
|
||||
f"公众号图片候选 {sheet_index + 1}/{math.ceil(len(candidates) / PER_SHEET)} · {len(candidates)} 张",
|
||||
fill="#1f2b2a",
|
||||
font=title_font,
|
||||
)
|
||||
for item_index, item in enumerate(page_items):
|
||||
col = item_index % COLS
|
||||
row = item_index // COLS
|
||||
x = col * CELL_WIDTH
|
||||
y = 48 + row * CELL_HEIGHT
|
||||
draw.rectangle((x + 5, y + 5, x + CELL_WIDTH - 5, y + CELL_HEIGHT - 5), fill="#ffffff", outline="#d6d7cf")
|
||||
try:
|
||||
with Image.open(ROOT / item["path"]) as source:
|
||||
preview = ImageOps.contain(source.convert("RGB"), (CELL_WIDTH - 24, 154))
|
||||
px = x + (CELL_WIDTH - preview.width) // 2
|
||||
py = y + 12 + (154 - preview.height) // 2
|
||||
sheet.paste(preview, (px, py))
|
||||
except Exception:
|
||||
pass
|
||||
source_name = Path(item["sources"][0]).stem if item["sources"] else "未知来源"
|
||||
source_name = source_name[:20]
|
||||
draw.text((x + 12, y + 170), f"#{item['id']:03d} {item['width']}×{item['height']}", fill="#376d5a", font=label_font)
|
||||
draw.text((x + 12, y + 192), source_name, fill="#596563", font=small_font)
|
||||
sheet.save(SHEETS / f"sheet-{sheet_index + 1:02d}.jpg", quality=90)
|
||||
|
||||
summary = {
|
||||
"unique": len(unique_entries),
|
||||
"candidates": len(candidates),
|
||||
"rejected_by_shape_or_size": len(unique_entries) - len(candidates),
|
||||
"sheets": math.ceil(len(candidates) / PER_SHEET),
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceDirectory = path.join(projectRoot, "gzhh");
|
||||
const outputDirectory = path.join(projectRoot, ".tmp", "gzhh-images", "raw");
|
||||
const manifestPath = path.join(projectRoot, ".tmp", "gzhh-images", "manifest.json");
|
||||
const concurrency = 8;
|
||||
const maxBytes = 20 * 1024 * 1024;
|
||||
|
||||
const extensionByType = new Map([
|
||||
["image/jpeg", ".jpg"],
|
||||
["image/png", ".png"],
|
||||
["image/webp", ".webp"],
|
||||
["image/gif", ".gif"],
|
||||
["image/svg+xml", ".svg"]
|
||||
]);
|
||||
|
||||
const markdownFiles = (await readdir(sourceDirectory)).filter((name) => name.endsWith(".md"));
|
||||
const imageMap = new Map();
|
||||
|
||||
for (const source of markdownFiles) {
|
||||
const markdown = await readFile(path.join(sourceDirectory, source), "utf8");
|
||||
for (const match of markdown.matchAll(/!\[([^\]]*)\]\((https?:\/\/[^\s\)]+)\)/g)) {
|
||||
const alt = match[1].trim();
|
||||
const url = match[2].replaceAll("&", "&").replaceAll("\\_", "_");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed.hostname !== "mmbiz.qpic.cn") continue;
|
||||
const existing = imageMap.get(parsed.href) ?? { url: parsed.href, sources: [], alts: [] };
|
||||
if (!existing.sources.includes(source)) existing.sources.push(source);
|
||||
if (alt && !existing.alts.includes(alt)) existing.alts.push(alt);
|
||||
imageMap.set(parsed.href, existing);
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const entries = [...imageMap.values()];
|
||||
const results = new Array(entries.length);
|
||||
const contentFiles = new Map();
|
||||
let cursor = 0;
|
||||
let completed = 0;
|
||||
|
||||
const download = async (entry, index) => {
|
||||
try {
|
||||
const response = await fetch(entry.url, {
|
||||
headers: {
|
||||
"user-agent": "Mozilla/5.0 (compatible; ClassArchive/1.0)",
|
||||
referer: "https://mp.weixin.qq.com/"
|
||||
},
|
||||
signal: AbortSignal.timeout(45_000)
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0].toLowerCase() ?? "";
|
||||
if (!contentType.startsWith("image/")) throw new Error(`Unexpected content type: ${contentType || "unknown"}`);
|
||||
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||
if (declaredLength > maxBytes) throw new Error(`Image exceeds ${maxBytes} bytes`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > maxBytes) throw new Error(`Image exceeds ${maxBytes} bytes`);
|
||||
const hash = createHash("sha256").update(buffer).digest("hex");
|
||||
const extension = extensionByType.get(contentType) ?? ".img";
|
||||
let file = contentFiles.get(hash);
|
||||
let duplicate = true;
|
||||
if (!file) {
|
||||
duplicate = false;
|
||||
file = `${String(index + 1).padStart(4, "0")}-${hash.slice(0, 12)}${extension}`;
|
||||
await writeFile(path.join(outputDirectory, file), buffer);
|
||||
contentFiles.set(hash, file);
|
||||
}
|
||||
return { ...entry, status: "ok", file, contentType, bytes: buffer.byteLength, hash, duplicate };
|
||||
} catch (error) {
|
||||
return { ...entry, status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = cursor++;
|
||||
if (index >= entries.length) return;
|
||||
results[index] = await download(entries[index], index);
|
||||
completed += 1;
|
||||
if (completed % 50 === 0 || completed === entries.length) {
|
||||
console.log(`Downloaded ${completed}/${entries.length}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
await writeFile(manifestPath, `${JSON.stringify(results, null, 2)}\n`, "utf8");
|
||||
|
||||
const successful = results.filter((item) => item.status === "ok");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
references: entries.length,
|
||||
successful: successful.length,
|
||||
failed: results.length - successful.length,
|
||||
uniqueContent: successful.filter((item) => !item.duplicate).length,
|
||||
bytes: successful.filter((item) => !item.duplicate).reduce((sum, item) => sum + item.bytes, 0),
|
||||
manifest: path.relative(projectRoot, manifestPath)
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / ".tmp" / "gzhh-images"
|
||||
AUDIT = BASE / "audit.json"
|
||||
OUTPUT = ROOT / "public" / "assets" / "wechat-archive"
|
||||
|
||||
SELECTED_IDS = [
|
||||
81, 84, 85, 88, 89,
|
||||
117, 121, 129, 132,
|
||||
145, 146, 150, 151, 152, 153,
|
||||
159, 160, 161, 162,
|
||||
176, 178, 180, 181,
|
||||
193, 196, 197,
|
||||
368, 370, 373, 375,
|
||||
382, 387, 388, 391,
|
||||
396, 405, 408, 411,
|
||||
446, 455, 459,
|
||||
470, 480, 490, 499,
|
||||
]
|
||||
|
||||
records = {item["id"]: item for item in json.loads(AUDIT.read_text(encoding="utf-8"))}
|
||||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
exported = []
|
||||
for image_id in SELECTED_IDS:
|
||||
record = records[image_id]
|
||||
source_path = ROOT / record["path"]
|
||||
output_name = f"wechat-{image_id:03d}.webp"
|
||||
output_path = OUTPUT / output_name
|
||||
with Image.open(source_path) as image:
|
||||
frame = ImageOps.exif_transpose(image).convert("RGB")
|
||||
frame.thumbnail((1800, 1800), Image.Resampling.LANCZOS)
|
||||
frame.save(output_path, "WEBP", quality=84, method=6)
|
||||
exported.append({
|
||||
"id": image_id,
|
||||
"file": output_name,
|
||||
"source": record.get("sources", [""])[0],
|
||||
"width": frame.width,
|
||||
"height": frame.height,
|
||||
"bytes": output_path.stat().st_size,
|
||||
})
|
||||
|
||||
(BASE / "selected-manifest.json").write_text(
|
||||
json.dumps(exported, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({
|
||||
"selected": len(exported),
|
||||
"bytes": sum(item["bytes"] for item in exported),
|
||||
"output": str(OUTPUT.relative_to(ROOT)),
|
||||
}, ensure_ascii=False))
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceDirectory = path.join(projectRoot, "gzhh");
|
||||
const outputFile = path.join(projectRoot, "src", "data", "articles.generated.json");
|
||||
|
||||
const cleanMarkdown = (value) =>
|
||||
value
|
||||
.replace(/!\[[^\]]*\]\([^\)]+\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
|
||||
.replace(/[*_`>#]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const getExcerpt = (body, title) => {
|
||||
const ignored = /(?:data:image|%3Csvg|transform=|阅读|点赞|分享|推荐|留言|2024届612|font-family|__bottom-bar__|sns_opr_btn|picture_content|page_content)/i;
|
||||
const lines = body
|
||||
.split(/\r?\n/)
|
||||
.map((line) => cleanMarkdown(line))
|
||||
.filter((line) => {
|
||||
if (!line || line === "(unknown)" || line === cleanMarkdown(title) || ignored.test(line)) return false;
|
||||
if (/^(?:原创|原文地址|图\d+|内容来自|[=\-]{3,})/.test(line)) return false;
|
||||
return /[\p{L}\p{N}]/u.test(line);
|
||||
});
|
||||
const excerptText = lines[0]?.length >= 32 ? lines[0] : lines.slice(0, 3).join(" ");
|
||||
return excerptText.length > 88 ? `${excerptText.slice(0, 88)}…` : excerptText;
|
||||
};
|
||||
|
||||
const getTitle = (raw, fileName) => {
|
||||
const heading = raw.match(/\r?\n([^\r\n]{2,120})\r?\n={3,}\r?\n/);
|
||||
const title = heading?.[1]?.trim();
|
||||
return title && title !== "(unknown)"
|
||||
? title
|
||||
: fileName.replace(/\.md$/i, "").replaceAll("_", " ");
|
||||
};
|
||||
|
||||
const getCategory = (title) => {
|
||||
if (/毕业|高考|成人礼|壮行|远足|百日誓师|教师风采|班级之星|612|陆壹贰|往年今日/.test(title)) {
|
||||
return /毕业|高考|成人礼|壮行/.test(title) ? "毕业时刻" : "班级记忆";
|
||||
}
|
||||
if (/考试|四六级|四.六级|CET|竞赛|考生|准考证|成绩|普通话|数学建模|挂科|重修|招生/.test(title)) {
|
||||
return "考试升学";
|
||||
}
|
||||
if (/春节|元旦|除夕|小年|圣诞|清明|父亲节|母亲节|儿童节|六一|五四|新年/.test(title)) {
|
||||
return "节日来信";
|
||||
}
|
||||
if (/通知|通告|说明|提醒|返校|课程表|网站|公众号|AI功能|招聘|邀请函/.test(title)) {
|
||||
return "校园通知";
|
||||
}
|
||||
return "班级记忆";
|
||||
};
|
||||
|
||||
const parseArticle = (fileName, raw) => {
|
||||
const title = getTitle(raw, fileName);
|
||||
const date = raw.match(/\b(20\d{2}-\d{2}-\d{2})\b/)?.[1] ?? "";
|
||||
const sourceUrl = raw.match(/原文地址:\s*\[?((?:https?:\/\/)?mp\.weixin\.qq\.com\/s\/[^\s\]\)]+)/)?.[1]
|
||||
?.replaceAll("\\_", "_") ?? "";
|
||||
const contentStart = raw.search(/>\s*原文地址:/);
|
||||
const body = contentStart >= 0 ? raw.slice(contentStart).split(/\r?\n/).slice(2).join("\n") : raw;
|
||||
const images = [...body.matchAll(/!\[[^\]]*\]\((https?:\/\/[^\s\)]+)\)/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((url) => /mmbiz\.qpic\.cn/.test(url));
|
||||
const excerptText = getExcerpt(body, title);
|
||||
|
||||
return {
|
||||
title,
|
||||
date,
|
||||
displayDate: date ? date.replaceAll("-", ".") : "日期待补",
|
||||
sourceUrl,
|
||||
cover: images[0] ?? "",
|
||||
excerpt: excerptText.length > 88 ? `${excerptText.slice(0, 88)}…` : excerptText || "一页从公众号找回的班级记录。",
|
||||
category: getCategory(title),
|
||||
fileName
|
||||
};
|
||||
};
|
||||
|
||||
const fileNames = (await readdir(sourceDirectory)).filter((fileName) => fileName.endsWith(".md"));
|
||||
const articles = (
|
||||
await Promise.all(
|
||||
fileNames.map(async (fileName) => parseArticle(fileName, await readFile(path.join(sourceDirectory, fileName), "utf8")))
|
||||
)
|
||||
)
|
||||
.filter((article) => article.sourceUrl)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || a.title.localeCompare(b.title, "zh-CN"));
|
||||
|
||||
await writeFile(outputFile, `${JSON.stringify(articles, null, 2)}\n`, "utf8");
|
||||
console.log(`Imported ${articles.length} articles from gzhh into ${path.relative(projectRoot, outputFile)}.`);
|
||||
@@ -0,0 +1,26 @@
|
||||
export type ArticleCategory = "班级记忆" | "毕业时刻" | "节日来信" | "考试升学" | "校园通知";
|
||||
|
||||
export interface WechatArticle {
|
||||
title: string;
|
||||
date: string;
|
||||
displayDate: string;
|
||||
sourceUrl: string;
|
||||
cover: string;
|
||||
excerpt: string;
|
||||
category: ArticleCategory;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
import articleData from "./articles.generated.json";
|
||||
|
||||
export const articles = articleData as WechatArticle[];
|
||||
|
||||
export const articleCategories: ArticleCategory[] = [
|
||||
"班级记忆",
|
||||
"毕业时刻",
|
||||
"节日来信",
|
||||
"考试升学",
|
||||
"校园通知"
|
||||
];
|
||||
|
||||
export const featuredArticles = articles.slice(0, 3);
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PhotoTopic } from "../photo-types";
|
||||
import { wechatPhoto } from "./wechat-photo";
|
||||
|
||||
export const candidTopic: PhotoTopic = {
|
||||
slug: "candid",
|
||||
title: "没被摆拍的瞬间",
|
||||
text: "走廊、食堂、晚霞和笑场。真正会让人停下来的,常常是不太整齐的照片。",
|
||||
cover: "",
|
||||
cover: "/assets/wechat-archive/wechat-499.webp",
|
||||
photos: [
|
||||
{
|
||||
title: "走廊偶遇",
|
||||
@@ -25,6 +26,8 @@ export const candidTopic: PhotoTopic = {
|
||||
title: "笑场",
|
||||
caption: "最不端正的照片,往往最像我们。",
|
||||
image: ""
|
||||
}
|
||||
},
|
||||
wechatPhoto(490, "恶魔角合照", "没有正式站姿,反而最像我们真实的青春。"),
|
||||
wechatPhoto(499, "草地上的伙伴", "坐下来聊聊天,晚风把时间吹得很慢。")
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PhotoTopic } from "../photo-types";
|
||||
import { wechatPhoto } from "./wechat-photo";
|
||||
|
||||
export const classroomTopic: PhotoTopic = {
|
||||
slug: "classroom",
|
||||
title: "教室日常",
|
||||
text: "黑板、课桌、窗边、试卷,还有那些写在草稿纸边角的小情绪。",
|
||||
cover: "",
|
||||
cover: "/assets/wechat-archive/wechat-373.webp",
|
||||
photos: [
|
||||
{
|
||||
title: "窗边",
|
||||
@@ -30,6 +31,15 @@ export const classroomTopic: PhotoTopic = {
|
||||
title: "询问老师",
|
||||
caption: "课后向老师请教问题",
|
||||
image: "https://pic.biss.click/image/95c03bd4-d0d3-4c38-9855-3342fd582080.jpg"
|
||||
}
|
||||
},
|
||||
wechatPhoto(159, "清晨校园", "天还没完全亮,教学楼已经开始新的一天。"),
|
||||
wechatPhoto(160, "空走廊", "下课铃没有响起时,走廊安静得像一张旧照片。"),
|
||||
wechatPhoto(161, "教室一角", "寒冷的早晨、厚厚的毯子和靠窗的位置。"),
|
||||
wechatPhoto(162, "雪天课间", "楼下有人追着雪跑,楼上的人隔窗看热闹。"),
|
||||
wechatPhoto(193, "黑板前", "老师写下的每一行,都曾是我们共同的日常。"),
|
||||
wechatPhoto(196, "忙碌的教室", "有人整理桌面,有人继续写题,生活没有暂停。"),
|
||||
wechatPhoto(197, "围在桌边", "讲一道题的时间,也能成为多年后的回忆。"),
|
||||
wechatPhoto(373, "夕阳自习", "光从窗边落进来,照亮高中的普通一天。"),
|
||||
wechatPhoto(375, "教室合照", "镜头前挤成一团,是班级最真实的样子。")
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PhotoTopic } from "../photo-types";
|
||||
import { wechatPhoto } from "./wechat-photo";
|
||||
|
||||
export const eventsTopic: PhotoTopic = {
|
||||
slug: "events",
|
||||
title: "班级活动",
|
||||
text: "运动会、晚会、春游、比赛,所有离开课桌之后还在一起发光的时刻。",
|
||||
cover: "",
|
||||
cover: "/assets/wechat-archive/wechat-455.webp",
|
||||
photos: [
|
||||
{
|
||||
title: "高一下学期表彰大会",
|
||||
@@ -20,6 +21,21 @@ export const eventsTopic: PhotoTopic = {
|
||||
title: "百日誓师大会",
|
||||
caption: "高三的第一次全体活动,大家都很随意地在听讲,虽然最后还是没能坚持到最后。",
|
||||
image: "https://pic.biss.click/image/45bfbe79-bd7b-40f3-9670-1b34cc0957b2.png"
|
||||
}
|
||||
},
|
||||
wechatPhoto(117, "考前舞台", "紧张的日子里,也需要一次大声唱出来。"),
|
||||
wechatPhoto(121, "看台上的我们", "一片红色校服,聚成高三最后的集体活动。"),
|
||||
wechatPhoto(129, "操场热身", "阳光很好,口号和动作都比平时更有力量。"),
|
||||
wechatPhoto(132, "坐在一起", "台上发生什么已经模糊,身边的人还记得。"),
|
||||
wechatPhoto(176, "元旦歌咏", "整齐站上舞台,是青春里难得的郑重。"),
|
||||
wechatPhoto(178, "领奖时刻", "证书会褪色,但被认可的那一刻不会。"),
|
||||
wechatPhoto(180, "操场挑战", "跨过栏架,也跨过一个个觉得做不到的瞬间。"),
|
||||
wechatPhoto(181, "百日倒计时", "数字揭开的那一刻,高考忽然变得很近。"),
|
||||
wechatPhoto(368, "军训合影", "刚认识不久的我们,第一次以班级的名字站在一起。"),
|
||||
wechatPhoto(370, "方阵出发", "步子未必完全整齐,方向却是一致的。"),
|
||||
wechatPhoto(446, "远足路上的笑脸", "路很长,但和同学一起走就没有那么累。"),
|
||||
wechatPhoto(455, "山脚合影", "走过很远的路,当然要认真留下一张合照。"),
|
||||
wechatPhoto(459, "班旗与伙伴", "把班级的名字举进镜头,也举进三年的记忆。"),
|
||||
wechatPhoto(470, "操场晚会", "天黑之后,灯光和欢呼把操场变成了舞台。"),
|
||||
wechatPhoto(480, "荧光人群", "挥动的光棒让普通夜晚有了节日的颜色。")
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PhotoTopic } from "../photo-types";
|
||||
import { wechatPhoto } from "./wechat-photo";
|
||||
|
||||
export const graduationDayTopic: PhotoTopic = {
|
||||
slug: "graduation-day",
|
||||
title: "毕业那天",
|
||||
text: "合照、签名、花束、校门和没说完的话,都放在这个专题里。",
|
||||
cover: "https://pic.biss.click/image/c1528af5-62da-47c3-8309-cd809f779288.jpg",
|
||||
cover: "/assets/wechat-archive/wechat-145.webp",
|
||||
photos: [
|
||||
{
|
||||
title: "最后一张合照",
|
||||
@@ -30,6 +31,25 @@ export const graduationDayTopic: PhotoTopic = {
|
||||
title: "散场之前",
|
||||
caption: "那一刻大家都在笑,但心里都知道要分别了。",
|
||||
image: "https://pic.biss.click/image/f8d180c1-4827-4bfd-9550-4bfad4b97640.jpg"
|
||||
}
|
||||
},
|
||||
wechatPhoto(145, "壮行合影", "红色班服、彩色气球,和出发前最完整的一次站在一起。"),
|
||||
wechatPhoto(146, "气球升起之前", "所有人挤进镜头,等一个一起松手的瞬间。"),
|
||||
wechatPhoto(150, "飞向夏天", "气球越过教学楼,属于高中的倒计时也到了最后。"),
|
||||
wechatPhoto(151, "人群里的告别", "欢呼、拥抱和一句句被现场声音盖住的话。"),
|
||||
wechatPhoto(152, "穿过祝福", "从熟悉的人群中走过,前面就是新的路。"),
|
||||
wechatPhoto(153, "鲜花与笑脸", "最后一程依然有人在身旁鼓掌。"),
|
||||
wechatPhoto(81, "毕业典礼合影", "红色幕布前,老师和同学一起留下毕业的证据。"),
|
||||
wechatPhoto(84, "典礼现场", "从看台望向舞台,礼堂装下了整届人的夏天。"),
|
||||
wechatPhoto(85, "把花送给老师", "镜头拼在一起,也拼出那天的感谢。"),
|
||||
wechatPhoto(88, "走过红毯", "熟悉的老师站在两侧,目送这一届学生离开。"),
|
||||
wechatPhoto(89, "并肩入场", "毕业不是一个人的终点,是一群人的共同章节。"),
|
||||
wechatPhoto(382, "最后一天的教室", "书本还摊在桌上,离别已经悄悄开始。"),
|
||||
wechatPhoto(387, "签在衣服上", "名字、祝福和画得歪歪扭扭的小图案。"),
|
||||
wechatPhoto(388, "气球装点的教室", "最后一次把教室布置得像节日。"),
|
||||
wechatPhoto(391, "散场之后", "桌椅、书本和气球,保留着刚刚发生过的热闹。"),
|
||||
wechatPhoto(396, "捧着花出发", "校门口的人群里,每个人都有自己的告别方式。"),
|
||||
wechatPhoto(405, "校车旁的合照", "气球还在手里,下一段旅程已经在等候。"),
|
||||
wechatPhoto(408, "放飞之前", "大家抬头看向同一个方向。"),
|
||||
wechatPhoto(411, "毕业气球", "彩色气球升空,校门口只剩挥手的人群。")
|
||||
]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PhotoItem } from "../photo-types";
|
||||
|
||||
export const wechatPhoto = (id: number, title: string, caption: string): PhotoItem => ({
|
||||
title,
|
||||
caption,
|
||||
image: `/assets/wechat-archive/wechat-${String(id).padStart(3, "0")}.webp`
|
||||
});
|
||||
@@ -31,6 +31,7 @@ export const navItems = [
|
||||
{ label: "首页", href: "/" },
|
||||
{ label: "三年时间线", href: "/timeline/" },
|
||||
{ label: "照片墙", href: "/gallery/" },
|
||||
{ label: "青春刊物", href: "/articles/" },
|
||||
{ label: "如今的我们", href: "/people/" },
|
||||
{ label: "综合试卷", href: "/exam/" },
|
||||
{ label: "留言墙", href: "/messages/" }
|
||||
|
||||
@@ -25,6 +25,7 @@ const buildTime = new Intl.DateTimeFormat("zh-CN", {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>{title}</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import { articleCategories, articles } from "../data/articles";
|
||||
|
||||
const categoryCounts = Object.fromEntries(
|
||||
articleCategories.map((category) => [category, articles.filter((article) => article.category === category).length])
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout title="青春刊物 · 2024届612班">
|
||||
<main class="page-main publication-page">
|
||||
<section class="page-hero publication-hero">
|
||||
<div class="section-inner">
|
||||
<a class="back-link" href="/">返回首页</a>
|
||||
<p class="eyebrow">CLASS PUBLICATION · {articles.length} 篇</p>
|
||||
<h1>青春刊物</h1>
|
||||
<p>这里收录公众号里的班级日志、校园时刻与毕业后的来信。原文仍属于公众号,这里为它们做一份更容易寻找的目录。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="article-archive">
|
||||
<div class="section-inner">
|
||||
<div class="archive-tools" aria-label="文章筛选工具">
|
||||
<label class="article-search">
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
<span class="sr-only">搜索文章</span>
|
||||
<input id="article-search" type="search" placeholder="搜索标题或摘要…" autocomplete="off" />
|
||||
</label>
|
||||
<div class="category-filters" role="group" aria-label="按分类筛选">
|
||||
<button class="is-active" type="button" data-category="全部">全部 <span>{articles.length}</span></button>
|
||||
{
|
||||
articleCategories.map((category) => (
|
||||
<button type="button" data-category={category}>{category} <span>{categoryCounts[category]}</span></button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="archive-status" aria-live="polite">正在展示 <strong>{articles.length}</strong> 篇文章</p>
|
||||
|
||||
<div class="article-list" id="article-list">
|
||||
{
|
||||
articles.map((article, index) => (
|
||||
<a
|
||||
class="archive-card"
|
||||
href={article.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-category={article.category}
|
||||
data-search={`${article.title} ${article.excerpt}`.toLocaleLowerCase("zh-CN")}
|
||||
>
|
||||
<div
|
||||
class="archive-cover"
|
||||
style={article.cover ? `--article-cover: url("${article.cover}")` : ""}
|
||||
>
|
||||
{!article.cover && <span>{String(index + 1).padStart(3, "0")}</span>}
|
||||
</div>
|
||||
<div class="archive-copy">
|
||||
<div class="archive-meta">
|
||||
<span>{article.category}</span>
|
||||
<time datetime={article.date}>{article.displayDate}</time>
|
||||
</div>
|
||||
<h2>{article.title}</h2>
|
||||
<p>{article.excerpt}</p>
|
||||
<span class="archive-read">公众号原文 <i class="fa-solid fa-arrow-up-right-from-square"></i></span>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="archive-empty" hidden>
|
||||
<span>没有找到相符的文章</span>
|
||||
<p>换一个关键词,或者查看其他分类吧。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const searchInput = document.querySelector<HTMLInputElement>("#article-search");
|
||||
const filterButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-category]")];
|
||||
const cards = [...document.querySelectorAll<HTMLElement>(".archive-card")];
|
||||
const status = document.querySelector<HTMLElement>(".archive-status");
|
||||
const empty = document.querySelector<HTMLElement>(".archive-empty");
|
||||
let activeCategory = "全部";
|
||||
|
||||
const updateResults = () => {
|
||||
const query = searchInput?.value.trim().toLocaleLowerCase("zh-CN") ?? "";
|
||||
let visibleCount = 0;
|
||||
|
||||
cards.forEach((card) => {
|
||||
const categoryMatches = activeCategory === "全部" || card.dataset.category === activeCategory;
|
||||
const searchMatches = !query || card.dataset.search?.includes(query);
|
||||
const isVisible = Boolean(categoryMatches && searchMatches);
|
||||
card.hidden = !isVisible;
|
||||
if (isVisible) visibleCount += 1;
|
||||
});
|
||||
|
||||
if (status) status.innerHTML = `正在展示 <strong>${visibleCount}</strong> 篇文章`;
|
||||
if (empty) empty.hidden = visibleCount !== 0;
|
||||
};
|
||||
|
||||
filterButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
activeCategory = button.dataset.category ?? "全部";
|
||||
filterButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
updateResults();
|
||||
});
|
||||
});
|
||||
|
||||
searchInput?.addEventListener("input", updateResults);
|
||||
</script>
|
||||
</BaseLayout>
|
||||
@@ -5,6 +5,7 @@ import { featuredMessage, messages, messagesIntro } from "../data/messages";
|
||||
import { people, peopleIntro } from "../data/people";
|
||||
import { site, stats } from "../data/site";
|
||||
import { timeline, timelineIntro } from "../data/timeline";
|
||||
import { articles, featuredArticles } from "../data/articles";
|
||||
|
||||
const previewTimeline = timeline.slice(0, 4);
|
||||
const previewPhotoTopics = photoTopics.slice(0, 4);
|
||||
@@ -136,6 +137,48 @@ const previewPeople = people.slice(0, 6);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="articles" class="publication-band">
|
||||
<div class="section-inner">
|
||||
<div class="section-title publication-title">
|
||||
<div>
|
||||
<p class="eyebrow">FROM WECHAT · 青春刊物</p>
|
||||
<h2>散落在公众号里的故事,也该被好好收藏</h2>
|
||||
</div>
|
||||
<p>
|
||||
已整理 {articles.length} 篇公众号文章。从班级日志、毕业典礼到节日来信,按下时间的书签,重新读一遍当时的我们。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="featured-articles">
|
||||
{
|
||||
featuredArticles.map((article, index) => (
|
||||
<a
|
||||
class:list={["article-card", index === 0 && "article-card-featured"]}
|
||||
href={article.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div
|
||||
class="article-cover"
|
||||
style={article.cover ? `--article-cover: url("${article.cover}")` : ""}
|
||||
>
|
||||
<span>{article.category}</span>
|
||||
</div>
|
||||
<div class="article-copy">
|
||||
<time datetime={article.date}>{article.displayDate}</time>
|
||||
<h3>{article.title}</h3>
|
||||
<p>{article.excerpt}</p>
|
||||
<span class="read-more">阅读原文 <i class="fa-solid fa-arrow-up-right-from-square"></i></span>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<a class="section-link publication-link" href="/articles/">翻阅全部 {articles.length} 篇</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="messages" class="gallery-band">
|
||||
<div class="section-inner">
|
||||
<div class="section-title">
|
||||
|
||||
@@ -1672,6 +1672,371 @@ h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* WeChat publication archive */
|
||||
.publication-band {
|
||||
color: #fffdf7;
|
||||
background:
|
||||
radial-gradient(circle at 90% 10%, rgba(232, 168, 76, 0.18), transparent 28%),
|
||||
linear-gradient(145deg, #172522, #294a40);
|
||||
}
|
||||
|
||||
.publication-title {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.publication-title .eyebrow {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.publication-title > p {
|
||||
color: rgba(255, 253, 247, 0.7);
|
||||
}
|
||||
|
||||
.featured-articles {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
min-height: 210px;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 253, 247, 0.15);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 253, 247, 0.08);
|
||||
transition: transform 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.article-card:hover {
|
||||
transform: translateY(-4px);
|
||||
background: rgba(255, 253, 247, 0.12);
|
||||
}
|
||||
|
||||
.article-card-featured {
|
||||
grid-row: span 2;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.article-cover,
|
||||
.archive-cover {
|
||||
position: relative;
|
||||
min-height: 170px;
|
||||
background:
|
||||
linear-gradient(0deg, rgba(20, 32, 29, 0.48), rgba(20, 32, 29, 0.06)),
|
||||
var(--article-cover, linear-gradient(135deg, #376d5a, #e8a84c));
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.article-card-featured .article-cover {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.article-cover > span {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(23, 37, 34, 0.72);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.article-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.article-copy time {
|
||||
color: #f3cf8b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.article-copy h3 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: 21px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.article-card-featured .article-copy h3 {
|
||||
font-size: clamp(24px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.article-copy p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: rgba(255, 253, 247, 0.68);
|
||||
font-size: 14px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
margin-top: auto;
|
||||
padding-top: 18px;
|
||||
color: #f3cf8b;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.read-more i,
|
||||
.archive-read i {
|
||||
margin-left: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.publication-link {
|
||||
border-color: rgba(255, 253, 247, 0.2);
|
||||
color: #fffdf7;
|
||||
background: rgba(255, 253, 247, 0.08);
|
||||
}
|
||||
|
||||
.publication-hero {
|
||||
background:
|
||||
radial-gradient(circle at 82% 30%, rgba(232, 168, 76, 0.28), transparent 25%),
|
||||
linear-gradient(135deg, #172522, #376d5a);
|
||||
}
|
||||
|
||||
.article-archive {
|
||||
padding-top: clamp(36px, 5vw, 64px);
|
||||
}
|
||||
|
||||
.archive-tools {
|
||||
position: sticky;
|
||||
z-index: 5;
|
||||
top: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 253, 247, 0.92);
|
||||
box-shadow: 0 14px 40px rgba(39, 55, 52, 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.article-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.article-search:focus-within {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 3px rgba(55, 109, 90, 0.12);
|
||||
}
|
||||
|
||||
.article-search input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.category-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.category-filters button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-filters button span {
|
||||
margin-left: 4px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.category-filters button.is-active {
|
||||
border-color: var(--green);
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.archive-status {
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.article-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.archive-card {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(39, 55, 52, 0.06);
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.archive-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(55, 109, 90, 0.34);
|
||||
box-shadow: 0 16px 38px rgba(39, 55, 52, 0.12);
|
||||
}
|
||||
|
||||
.archive-card[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.archive-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.archive-cover > span {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.archive-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.archive-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.archive-meta span {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.archive-copy h2 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 10px 0 8px;
|
||||
font-size: 19px;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.archive-copy p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.archive-read {
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.archive-empty {
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.archive-empty span {
|
||||
color: var(--ink);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.archive-empty p {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.featured-articles,
|
||||
.article-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.article-card-featured {
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.article-card-featured .article-cover {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.article-card,
|
||||
.article-card-featured,
|
||||
.archive-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.article-card .article-cover,
|
||||
.article-card-featured .article-cover,
|
||||
.archive-cover {
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.archive-tools {
|
||||
top: 8px;
|
||||
margin-inline: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||