gengxin
@@ -3,3 +3,5 @@ dist/
|
|||||||
.astro/
|
.astro/
|
||||||
*.log
|
*.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 type { PhotoTopic } from "../photo-types";
|
||||||
|
import { wechatPhoto } from "./wechat-photo";
|
||||||
|
|
||||||
export const candidTopic: PhotoTopic = {
|
export const candidTopic: PhotoTopic = {
|
||||||
slug: "candid",
|
slug: "candid",
|
||||||
title: "没被摆拍的瞬间",
|
title: "没被摆拍的瞬间",
|
||||||
text: "走廊、食堂、晚霞和笑场。真正会让人停下来的,常常是不太整齐的照片。",
|
text: "走廊、食堂、晚霞和笑场。真正会让人停下来的,常常是不太整齐的照片。",
|
||||||
cover: "",
|
cover: "/assets/wechat-archive/wechat-499.webp",
|
||||||
photos: [
|
photos: [
|
||||||
{
|
{
|
||||||
title: "走廊偶遇",
|
title: "走廊偶遇",
|
||||||
@@ -25,6 +26,8 @@ export const candidTopic: PhotoTopic = {
|
|||||||
title: "笑场",
|
title: "笑场",
|
||||||
caption: "最不端正的照片,往往最像我们。",
|
caption: "最不端正的照片,往往最像我们。",
|
||||||
image: ""
|
image: ""
|
||||||
}
|
},
|
||||||
|
wechatPhoto(490, "恶魔角合照", "没有正式站姿,反而最像我们真实的青春。"),
|
||||||
|
wechatPhoto(499, "草地上的伙伴", "坐下来聊聊天,晚风把时间吹得很慢。")
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { PhotoTopic } from "../photo-types";
|
import type { PhotoTopic } from "../photo-types";
|
||||||
|
import { wechatPhoto } from "./wechat-photo";
|
||||||
|
|
||||||
export const classroomTopic: PhotoTopic = {
|
export const classroomTopic: PhotoTopic = {
|
||||||
slug: "classroom",
|
slug: "classroom",
|
||||||
title: "教室日常",
|
title: "教室日常",
|
||||||
text: "黑板、课桌、窗边、试卷,还有那些写在草稿纸边角的小情绪。",
|
text: "黑板、课桌、窗边、试卷,还有那些写在草稿纸边角的小情绪。",
|
||||||
cover: "",
|
cover: "/assets/wechat-archive/wechat-373.webp",
|
||||||
photos: [
|
photos: [
|
||||||
{
|
{
|
||||||
title: "窗边",
|
title: "窗边",
|
||||||
@@ -30,6 +31,15 @@ export const classroomTopic: PhotoTopic = {
|
|||||||
title: "询问老师",
|
title: "询问老师",
|
||||||
caption: "课后向老师请教问题",
|
caption: "课后向老师请教问题",
|
||||||
image: "https://pic.biss.click/image/95c03bd4-d0d3-4c38-9855-3342fd582080.jpg"
|
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 type { PhotoTopic } from "../photo-types";
|
||||||
|
import { wechatPhoto } from "./wechat-photo";
|
||||||
|
|
||||||
export const eventsTopic: PhotoTopic = {
|
export const eventsTopic: PhotoTopic = {
|
||||||
slug: "events",
|
slug: "events",
|
||||||
title: "班级活动",
|
title: "班级活动",
|
||||||
text: "运动会、晚会、春游、比赛,所有离开课桌之后还在一起发光的时刻。",
|
text: "运动会、晚会、春游、比赛,所有离开课桌之后还在一起发光的时刻。",
|
||||||
cover: "",
|
cover: "/assets/wechat-archive/wechat-455.webp",
|
||||||
photos: [
|
photos: [
|
||||||
{
|
{
|
||||||
title: "高一下学期表彰大会",
|
title: "高一下学期表彰大会",
|
||||||
@@ -20,6 +21,21 @@ export const eventsTopic: PhotoTopic = {
|
|||||||
title: "百日誓师大会",
|
title: "百日誓师大会",
|
||||||
caption: "高三的第一次全体活动,大家都很随意地在听讲,虽然最后还是没能坚持到最后。",
|
caption: "高三的第一次全体活动,大家都很随意地在听讲,虽然最后还是没能坚持到最后。",
|
||||||
image: "https://pic.biss.click/image/45bfbe79-bd7b-40f3-9670-1b34cc0957b2.png"
|
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 type { PhotoTopic } from "../photo-types";
|
||||||
|
import { wechatPhoto } from "./wechat-photo";
|
||||||
|
|
||||||
export const graduationDayTopic: PhotoTopic = {
|
export const graduationDayTopic: PhotoTopic = {
|
||||||
slug: "graduation-day",
|
slug: "graduation-day",
|
||||||
title: "毕业那天",
|
title: "毕业那天",
|
||||||
text: "合照、签名、花束、校门和没说完的话,都放在这个专题里。",
|
text: "合照、签名、花束、校门和没说完的话,都放在这个专题里。",
|
||||||
cover: "https://pic.biss.click/image/c1528af5-62da-47c3-8309-cd809f779288.jpg",
|
cover: "/assets/wechat-archive/wechat-145.webp",
|
||||||
photos: [
|
photos: [
|
||||||
{
|
{
|
||||||
title: "最后一张合照",
|
title: "最后一张合照",
|
||||||
@@ -30,6 +31,25 @@ export const graduationDayTopic: PhotoTopic = {
|
|||||||
title: "散场之前",
|
title: "散场之前",
|
||||||
caption: "那一刻大家都在笑,但心里都知道要分别了。",
|
caption: "那一刻大家都在笑,但心里都知道要分别了。",
|
||||||
image: "https://pic.biss.click/image/f8d180c1-4827-4bfd-9550-4bfad4b97640.jpg"
|
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: "/" },
|
||||||
{ label: "三年时间线", href: "/timeline/" },
|
{ label: "三年时间线", href: "/timeline/" },
|
||||||
{ label: "照片墙", href: "/gallery/" },
|
{ label: "照片墙", href: "/gallery/" },
|
||||||
|
{ label: "青春刊物", href: "/articles/" },
|
||||||
{ label: "如今的我们", href: "/people/" },
|
{ label: "如今的我们", href: "/people/" },
|
||||||
{ label: "综合试卷", href: "/exam/" },
|
{ label: "综合试卷", href: "/exam/" },
|
||||||
{ label: "留言墙", href: "/messages/" }
|
{ label: "留言墙", href: "/messages/" }
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const buildTime = new Intl.DateTimeFormat("zh-CN", {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
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 { people, peopleIntro } from "../data/people";
|
||||||
import { site, stats } from "../data/site";
|
import { site, stats } from "../data/site";
|
||||||
import { timeline, timelineIntro } from "../data/timeline";
|
import { timeline, timelineIntro } from "../data/timeline";
|
||||||
|
import { articles, featuredArticles } from "../data/articles";
|
||||||
|
|
||||||
const previewTimeline = timeline.slice(0, 4);
|
const previewTimeline = timeline.slice(0, 4);
|
||||||
const previewPhotoTopics = photoTopics.slice(0, 4);
|
const previewPhotoTopics = photoTopics.slice(0, 4);
|
||||||
@@ -136,6 +137,48 @@ const previewPeople = people.slice(0, 6);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section id="messages" class="gallery-band">
|
||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
|
|||||||
@@ -1672,6 +1672,371 @@ h2 {
|
|||||||
font-size: 14px;
|
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 {
|
.site-footer p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||