|
|
@@ -0,0 +1,364 @@
|
|
|
+import React from "react";
|
|
|
+import { Img, staticFile } from "remotion";
|
|
|
+import type { RenderSegmentExtension } from "@pipeline/shared";
|
|
|
+import { formatCount } from "@pipeline/shared";
|
|
|
+import { Rise, Pop, DrawLine, CountUp, RevealImage } from "./motion";
|
|
|
+import { ColorDot } from "../../github-trending/index";
|
|
|
+
|
|
|
+/**
|
|
|
+ * BoardPortrait — the shared portrait Hero board for the github boards:
|
|
|
+ * github-trending (skin "daily-report") and github-weekly / github-weekly-recap
|
|
|
+ * (skin "weekly-magazine"). One skeleton, two skins:
|
|
|
+ *
|
|
|
+ * daily-report square-cornered data report: ghosted rank numeral, top
|
|
|
+ * data-bar chips, wide-tracked report labels.
|
|
|
+ * weekly-magazine rounded editorial magazine: pill chips, left editorial
|
|
|
+ * bars, softer radii, no rank numeral.
|
|
|
+ *
|
|
|
+ * Layout (top→bottom inside the scene's flex column; the caller renders the
|
|
|
+ * subtitle band and chapter toc below the reserved footer):
|
|
|
+ * repo fullName header → tag row pops in one by one → divider draws itself →
|
|
|
+ * full-width social-preview HERO (clip reveal + slow settle) with the
|
|
|
+ * star-history mini-card tucked bottom-right → highlights card → review card.
|
|
|
+ *
|
|
|
+ * All entrances are staggered frame-driven motion from ./motion.
|
|
|
+ */
|
|
|
+
|
|
|
+/** Subtitle band + chapter toc strip reserved below the content column. */
|
|
|
+export const PORTRAIT_FOOTER_RESERVED = 340;
|
|
|
+
|
|
|
+export type BoardSkin = "daily-report" | "weekly-magazine";
|
|
|
+
|
|
|
+export interface BoardPortraitProps {
|
|
|
+ palette: { from: string; to: string; accent: string };
|
|
|
+ github: RenderSegmentExtension;
|
|
|
+ skin: BoardSkin;
|
|
|
+ /** 1-based position among content scenes — drives the ghosted rank numeral
|
|
|
+ * (daily-report only). Undefined = no numeral. */
|
|
|
+ rank?: number;
|
|
|
+ /** Star-gain chip prefix: "今日" (daily) / "本周" (weekly). */
|
|
|
+ gainPrefix?: string;
|
|
|
+ /** Recap mode: renders the featured-date chip ("推荐 · 8月12日") instead of
|
|
|
+ * a star gain. ISO date, e.g. "2026-08-12". */
|
|
|
+ coveredDate?: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface SkinTable {
|
|
|
+ showRank: boolean;
|
|
|
+ chipRadius: number;
|
|
|
+ cardRadius: number;
|
|
|
+ /** Card accent position: top data bar (report) vs left editorial bar (magazine). */
|
|
|
+ cardBar: "top" | "left";
|
|
|
+ cardBg: string;
|
|
|
+ cardBorder: string;
|
|
|
+ heroRadius: number;
|
|
|
+ heroShadow: string;
|
|
|
+}
|
|
|
+
|
|
|
+const SKINS: Record<BoardSkin, SkinTable> = {
|
|
|
+ "daily-report": {
|
|
|
+ showRank: true,
|
|
|
+ chipRadius: 0,
|
|
|
+ cardRadius: 4,
|
|
|
+ cardBar: "top",
|
|
|
+ cardBg: "rgba(255,255,255,0.88)",
|
|
|
+ cardBorder: "rgba(30,41,59,0.12)",
|
|
|
+ heroRadius: 12,
|
|
|
+ heroShadow: "0 18px 44px rgba(30,41,59,0.18), 0 4px 12px rgba(30,41,59,0.10)",
|
|
|
+ },
|
|
|
+ "weekly-magazine": {
|
|
|
+ showRank: false,
|
|
|
+ chipRadius: 999,
|
|
|
+ cardRadius: 10,
|
|
|
+ cardBar: "left",
|
|
|
+ cardBg: "rgba(255,255,255,0.82)",
|
|
|
+ cardBorder: "rgba(30,41,59,0.10)",
|
|
|
+ heroRadius: 16,
|
|
|
+ heroShadow: "0 18px 44px rgba(120,100,60,0.16), 0 4px 12px rgba(120,100,60,0.10)",
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+export const BoardPortrait: React.FC<BoardPortraitProps> = ({
|
|
|
+ palette,
|
|
|
+ github,
|
|
|
+ skin,
|
|
|
+ rank,
|
|
|
+ gainPrefix = "今日",
|
|
|
+ coveredDate,
|
|
|
+}) => {
|
|
|
+ const s = SKINS[skin];
|
|
|
+ const repo = github.repo;
|
|
|
+ const language = repo?.language || "";
|
|
|
+ const languageColor = repo?.languageColor || palette.accent;
|
|
|
+ const stars = repo?.stars;
|
|
|
+ const license = repo?.license || "";
|
|
|
+ const fullName = repo?.fullName || "";
|
|
|
+ // Recap scenes tag with the featured date (snapshot numbers are feature-time,
|
|
|
+ // so a gain label would be wrong there) — same rule as the landscape header.
|
|
|
+ const gainLabel = coveredDate
|
|
|
+ ? `推荐 · ${coveredDate.replace(/^\d{4}-/, "").replace("-", "月").replace(/^0/, "")}日`
|
|
|
+ : repo?.todayStars != null
|
|
|
+ ? `${gainPrefix} +${formatCount(repo.todayStars)}`
|
|
|
+ : "";
|
|
|
+
|
|
|
+ const socialPreview = github.images?.socialPreview;
|
|
|
+ const starHistory = github.images?.starHistory;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: "relative",
|
|
|
+ display: "flex",
|
|
|
+ flexDirection: "column",
|
|
|
+ width: "100%",
|
|
|
+ height: "100%",
|
|
|
+ boxSizing: "border-box",
|
|
|
+ padding: `48px 48px ${PORTRAIT_FOOTER_RESERVED}px`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* Ghosted rank numeral behind the header — the daily report's chapter
|
|
|
+ number, huge and faint. The magazine skin omits it. */}
|
|
|
+ {s.showRank && rank != null && (
|
|
|
+ <Rise
|
|
|
+ delay={0}
|
|
|
+ distance={24}
|
|
|
+ style={{
|
|
|
+ position: "absolute",
|
|
|
+ top: 8,
|
|
|
+ right: 40,
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ fontWeight: 800,
|
|
|
+ fontSize: 220,
|
|
|
+ lineHeight: 1,
|
|
|
+ color: palette.accent,
|
|
|
+ opacity: 0.1,
|
|
|
+ pointerEvents: "none",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {String(rank).padStart(2, "0")}
|
|
|
+ </Rise>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* --- Header --- */}
|
|
|
+ <Rise delay={2} style={{ position: "relative", zIndex: 1 }}>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ fontSize: 84,
|
|
|
+ fontWeight: 800,
|
|
|
+ color: "#1e293b",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ lineHeight: 1.15,
|
|
|
+ letterSpacing: "-0.01em",
|
|
|
+ overflowWrap: "break-word",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {fullName}
|
|
|
+ </div>
|
|
|
+ </Rise>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: "flex",
|
|
|
+ flexWrap: "wrap",
|
|
|
+ gap: 14,
|
|
|
+ marginTop: 22,
|
|
|
+ position: "relative",
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {language && (
|
|
|
+ <Pop delay={12}>
|
|
|
+ <Chip radius={s.chipRadius} border={`${palette.accent}44`} topBar={s.cardBar === "top" ? palette.accent : undefined}>
|
|
|
+ <ColorDot color={languageColor} size={16} />
|
|
|
+ {language}
|
|
|
+ </Chip>
|
|
|
+ </Pop>
|
|
|
+ )}
|
|
|
+ {gainLabel && (
|
|
|
+ <Pop delay={17}>
|
|
|
+ <Chip
|
|
|
+ radius={s.chipRadius}
|
|
|
+ bg={palette.accent}
|
|
|
+ border={palette.accent}
|
|
|
+ topBar={s.cardBar === "top" ? palette.accent : undefined}
|
|
|
+ emphasis
|
|
|
+ >
|
|
|
+ {gainLabel}
|
|
|
+ </Chip>
|
|
|
+ </Pop>
|
|
|
+ )}
|
|
|
+ {stars != null && (
|
|
|
+ <Pop delay={22}>
|
|
|
+ <Chip radius={s.chipRadius} border={`${palette.accent}44`} topBar={s.cardBar === "top" ? palette.accent : undefined}>
|
|
|
+ <CountUp value={stars} format={formatCount} delay={24} duration={34} />
|
|
|
+ {" stars"}
|
|
|
+ </Chip>
|
|
|
+ </Pop>
|
|
|
+ )}
|
|
|
+ {license && (
|
|
|
+ <Pop delay={27}>
|
|
|
+ <Chip radius={s.chipRadius} border={`${palette.accent}44`} topBar={s.cardBar === "top" ? palette.accent : undefined}>
|
|
|
+ {license}
|
|
|
+ </Chip>
|
|
|
+ </Pop>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* --- Divider draws itself --- */}
|
|
|
+ <DrawLine delay={16} duration={22} color="rgba(30,41,59,0.18)" style={{ marginTop: 24 }} />
|
|
|
+
|
|
|
+ {/* --- Content column --- */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ display: "flex",
|
|
|
+ flexDirection: "column",
|
|
|
+ gap: 24,
|
|
|
+ paddingTop: 24,
|
|
|
+ minHeight: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* Hero: full-width social preview with the star-history mini-card
|
|
|
+ tucked into its bottom-right corner */}
|
|
|
+ {socialPreview && (
|
|
|
+ <Rise delay={18} distance={44}>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: "relative",
|
|
|
+ background: "#ffffff",
|
|
|
+ border: "1px solid rgba(30,41,59,0.12)",
|
|
|
+ borderRadius: s.heroRadius,
|
|
|
+ boxShadow: s.heroShadow,
|
|
|
+ overflow: "hidden",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <RevealImage filename={socialPreview.filename} delay={26} duration={26} />
|
|
|
+ {starHistory && (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: "absolute",
|
|
|
+ right: 18,
|
|
|
+ bottom: 18,
|
|
|
+ width: "38%",
|
|
|
+ background: "#ffffff",
|
|
|
+ border: "1px solid rgba(30,41,59,0.14)",
|
|
|
+ borderRadius: Math.max(8, s.heroRadius - 4),
|
|
|
+ boxShadow: "0 10px 28px rgba(30,41,59,0.22)",
|
|
|
+ overflow: "hidden",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Rise delay={44} distance={26}>
|
|
|
+ <Img
|
|
|
+ src={staticFile(starHistory.filename)}
|
|
|
+ style={{ display: "block", width: "100%", height: "auto" }}
|
|
|
+ />
|
|
|
+ </Rise>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </Rise>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Highlights / review cards rise in sequence */}
|
|
|
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 24, minHeight: 0 }}>
|
|
|
+ <Rise delay={34} distance={40} style={{ flex: 1, minHeight: 0 }}>
|
|
|
+ <SectionCard title="项目亮点" accent={palette.accent} body={github.highlights} s={s} />
|
|
|
+ </Rise>
|
|
|
+ <Rise delay={44} distance={40} style={{ flex: 1, minHeight: 0 }}>
|
|
|
+ <SectionCard title="建议" accent={palette.accent} body={github.review} s={s} />
|
|
|
+ </Rise>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+// --- Pieces ----------------------------------------------------------------
|
|
|
+
|
|
|
+const Chip: React.FC<{
|
|
|
+ children: React.ReactNode;
|
|
|
+ radius: number;
|
|
|
+ bg?: string;
|
|
|
+ border: string;
|
|
|
+ topBar?: string;
|
|
|
+ emphasis?: boolean;
|
|
|
+}> = ({ children, radius, bg, border, topBar, emphasis }) => (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: "inline-flex",
|
|
|
+ alignItems: "center",
|
|
|
+ gap: 10,
|
|
|
+ padding: "12px 24px",
|
|
|
+ background: bg ?? "rgba(255,255,255,0.8)",
|
|
|
+ border: `1px solid ${border}`,
|
|
|
+ borderTop: topBar ? `3px solid ${topBar}` : `1px solid ${border}`,
|
|
|
+ borderRadius: radius,
|
|
|
+ color: emphasis ? "#ffffff" : "#334155",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ fontSize: 32,
|
|
|
+ fontWeight: emphasis ? 700 : 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+);
|
|
|
+
|
|
|
+const SectionCard: React.FC<{
|
|
|
+ title: string;
|
|
|
+ accent: string;
|
|
|
+ body: string;
|
|
|
+ s: SkinTable;
|
|
|
+}> = ({ title, accent, body, s }) => {
|
|
|
+ // Strip trailing punctuation that would look awkward at the end of the
|
|
|
+ // clamped text (LLM occasionally leaves a hanging 。,,;).
|
|
|
+ const cleanBody = body.replace(/[。,、,.;;::\s]+$/, "");
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ height: "100%",
|
|
|
+ background: s.cardBg,
|
|
|
+ border: `1px solid ${s.cardBorder}`,
|
|
|
+ borderTop: s.cardBar === "top" ? `3px solid ${accent}` : undefined,
|
|
|
+ borderLeft: s.cardBar === "left" ? `6px solid ${accent}` : undefined,
|
|
|
+ borderRadius: s.cardRadius,
|
|
|
+ padding: "24px 32px",
|
|
|
+ display: "flex",
|
|
|
+ flexDirection: "column",
|
|
|
+ gap: 12,
|
|
|
+ minHeight: 0,
|
|
|
+ boxSizing: "border-box",
|
|
|
+ overflow: "hidden",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
|
+ <span
|
|
|
+ style={{
|
|
|
+ fontSize: 34,
|
|
|
+ fontWeight: 700,
|
|
|
+ color: accent,
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ letterSpacing: s.cardBar === "top" ? "0.14em" : "0.04em",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {title}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ flex: "0 0 auto",
|
|
|
+ fontSize: 60,
|
|
|
+ lineHeight: 1.35,
|
|
|
+ color: "#334155",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ fontWeight: 400,
|
|
|
+ display: "-webkit-box",
|
|
|
+ WebkitLineClamp: 3,
|
|
|
+ WebkitBoxOrient: "vertical",
|
|
|
+ overflow: "hidden",
|
|
|
+ overflowWrap: "break-word",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {cleanBody}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|