This commit is contained in:
Your Name 2025-12-18 14:35:26 -05:00
parent 5394a1282b
commit 1e49157a3a
3 changed files with 340 additions and 0 deletions

View File

@ -0,0 +1,195 @@
import React, { useMemo } from "react";
function getByPath(obj, path) {
if (!path) return undefined;
const parts = String(path).split(".");
let cur = obj;
for (const p of parts) {
if (cur == null) return undefined;
cur = cur[p];
}
return cur;
}
function resolveStyleToken(styleKey, tokens) {
const key = styleKey || "";
const [group, name] = key.split(".");
if (!group || !name) return {};
const groupObj = tokens?.[group];
return groupObj?.[name] ?? {};
}
function ClampText({ text, maxLines }) {
if (!maxLines || maxLines <= 0) return <span>{text}</span>;
return (
<div
style={{
display: "-webkit-box",
WebkitLineClamp: maxLines,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{text}
</div>
);
}
function LayerView({ layer, ctx, themeTokens, onAction }) {
const common = {
position: "absolute",
left: layer.x,
top: layer.y,
width: layer.w || "auto",
height: layer.h || "auto",
borderRadius: layer.radius ?? undefined,
};
const styleToken = resolveStyleToken(layer.style, themeTokens);
const clickable = !!layer.action;
const handleClick = () => {
if (!layer.action || !onAction) return;
const resolved = layer.action.bind
? getByPath(ctx, layer.action.bind)
: layer.action.value;
onAction(layer.action, resolved);
};
if (layer.type === "text") {
const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
return (
<div
style={{
...common,
...styleToken,
overflow: "hidden",
textOverflow: "ellipsis",
cursor: clickable ? "pointer" : "default",
}}
onClick={clickable ? handleClick : undefined}
>
<ClampText text={String(value ?? "")} maxLines={layer.maxLines || 0} />
</div>
);
}
if (layer.type === "chip") {
const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
return (
<div
style={{
...common,
...styleToken,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "6px 10px",
cursor: clickable ? "pointer" : "default",
}}
onClick={clickable ? handleClick : undefined}
>
{String(value ?? "")}
</div>
);
}
if (layer.type === "image") {
const src = layer.bind ? getByPath(ctx, layer.bind) : undefined;
return (
<div
style={{
...common,
...styleToken,
backgroundImage: src ? `url(${src})` : undefined,
backgroundSize: "cover",
backgroundPosition: "center",
cursor: clickable ? "pointer" : "default",
}}
onClick={clickable ? handleClick : undefined}
/>
);
}
if (layer.type === "button") {
const label = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
return (
<button
style={{
...common,
...styleToken,
border: "none",
cursor: "pointer",
}}
onClick={handleClick}
>
{String(label ?? "Open")}
</button>
);
}
if (layer.type === "icon") {
const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
return <div style={{ ...common, ...styleToken }}>{String(value ?? "•")}</div>;
}
return null;
}
export default function CardRenderer({
spec,
data,
themeTokens,
onAction,
className = "",
}) {
const ctx = useMemo(() => ({ data }), [data]);
const bgStyle = useMemo(() => {
const bg = spec?.background || {};
if (bg.type === "solid") {
return { background: themeTokens.colors?.[bg.value] ?? bg.value };
}
if (bg.type === "gradient") {
return { background: themeTokens.gradients?.[bg.value] ?? bg.value };
}
if (bg.type === "image") {
const url = bg.bind ? getByPath(ctx, bg.bind) : undefined;
return url
? {
backgroundImage: `url(${url})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: { background: themeTokens.colors?.surface ?? "#111" };
}
return { background: themeTokens.colors?.surface ?? "#111" };
}, [spec, themeTokens, ctx]);
if (!spec) return null;
return (
<div
className={className}
style={{
position: "relative",
width: spec.size.w,
height: spec.size.h,
borderRadius: spec.radius ?? themeTokens.radius?.card ?? 18,
overflow: "hidden",
boxShadow: themeTokens.shadow?.card,
...bgStyle,
}}
>
{spec.layers.map((layer) => (
<LayerView
key={layer.id}
layer={layer}
ctx={ctx}
themeTokens={themeTokens}
onAction={onAction}
/>
))}
</div>
);
}

View File

@ -0,0 +1,60 @@
import React, { useMemo } from "react";
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
// Spec NEWS (mini) identique à celui en DB (pour demo local)
const NEWS_MINI_SPEC = {
size: { w: 280, h: 110 },
background: { type: "gradient", value: "newsBlue" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 14, y: 12, text: "NEWS", style: "chip.news" },
{ id: "title", type: "text", x: 14, y: 40, w: 252, h: 44, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 14, y: 88, w: 252, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 }
],
};
export default function PostCardTemplate({ post, theme = "blue", onOpen }) {
// Adaptation depuis ton objet post actuel vers data_json style template
const data = useMemo(() => {
return {
headline: post.title || "Untitled",
source: post.author ? `by ${post.author}` : (post.sub_category || ""),
summary: post.snippet || post.body || "",
url: post.url || "",
image: post.image || post.media_url || "",
};
}, [post]);
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<CardRenderer
spec={NEWS_MINI_SPEC}
data={data}
themeTokens={cardTokens[theme] || cardTokens.blue}
onAction={(action, value) => {
if (action.type === "open_url" && value) window.open(value, "_blank");
}}
className=""
/>
{onOpen && (
<button
onClick={() => onOpen(post)}
style={{
marginLeft: 10,
borderRadius: 999,
padding: "8px 12px",
border: "1px solid rgba(148,163,184,.7)",
background: "rgba(15,23,42,.7)",
color: "#e5e7eb",
cursor: "pointer",
fontWeight: 800,
fontSize: 12,
}}
>
Open
</button>
)}
</div>
);
}

85
src/theme/cardTokens.js Normal file
View File

@ -0,0 +1,85 @@
export const cardTokens = {
dark: {
colors: {
surface: "#101218",
text: "#E9EEF8",
muted: "#A9B3C7",
accent: "#4EA1FF",
danger: "#FF4D4D",
},
gradients: {
newsBlue: "linear-gradient(135deg, rgba(78,161,255,0.35), rgba(16,18,24,1))",
},
radius: { card: 18 },
shadow: { card: "0 10px 30px rgba(0,0,0,0.35)" },
text: {
title: { color: "#E9EEF8", fontSize: 16, fontWeight: 800, lineHeight: "20px" },
meta: { color: "#A9B3C7", fontSize: 12, fontWeight: 600 },
h1: { color: "#E9EEF8", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#D7DEED", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
},
chip: {
news: { background: "rgba(78,161,255,0.18)", color: "#CFE6FF", fontWeight: 800, fontSize: 12, borderRadius: 999 },
danger: { background: "rgba(255,77,77,0.18)", color: "#FFD1D1", fontWeight: 900, fontSize: 12, borderRadius: 999 },
},
button: {
primary: { background: "rgba(78,161,255,0.22)", color: "#E9EEF8", fontWeight: 900, borderRadius: 14 },
},
},
blue: {
colors: {
surface: "#071225",
text: "#E9EEF8",
muted: "#B5C7E6",
accent: "#38bdf8",
danger: "#FF4D4D",
},
gradients: {
newsBlue: "linear-gradient(135deg, rgba(56,189,248,0.35), rgba(7,18,37,1))",
},
radius: { card: 18 },
shadow: { card: "0 10px 30px rgba(0,0,0,0.40)" },
text: {
title: { color: "#F0F7FF", fontSize: 16, fontWeight: 900, lineHeight: "20px" },
meta: { color: "#B5C7E6", fontSize: 12, fontWeight: 700 },
h1: { color: "#F0F7FF", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#D7E9FF", fontSize: 14, fontWeight: 700, lineHeight: "18px" },
},
chip: {
news: { background: "rgba(56,189,248,0.18)", color: "#D7F3FF", fontWeight: 900, fontSize: 12, borderRadius: 999 },
danger: { background: "rgba(255,77,77,0.18)", color: "#FFD1D1", fontWeight: 900, fontSize: 12, borderRadius: 999 },
},
button: {
primary: { background: "rgba(56,189,248,0.22)", color: "#F0F7FF", fontWeight: 900, borderRadius: 14 },
},
},
light: {
colors: {
surface: "#FFFFFF",
text: "#0B1220",
muted: "#5B677A",
accent: "#1E6BFF",
danger: "#D1001F",
},
gradients: {
newsBlue: "linear-gradient(135deg, rgba(30,107,255,0.20), rgba(255,255,255,1))",
},
radius: { card: 18 },
shadow: { card: "0 10px 24px rgba(0,0,0,0.12)" },
text: {
title: { color: "#0B1220", fontSize: 16, fontWeight: 900, lineHeight: "20px" },
meta: { color: "#5B677A", fontSize: 12, fontWeight: 700 },
h1: { color: "#0B1220", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#1A2740", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
},
chip: {
news: { background: "rgba(30,107,255,0.12)", color: "#0B1220", fontWeight: 900, fontSize: 12, borderRadius: 999 },
danger: { background: "rgba(209,0,31,0.10)", color: "#0B1220", fontWeight: 900, fontSize: 12, borderRadius: 999 },
},
button: {
primary: { background: "rgba(30,107,255,0.14)", color: "#0B1220", fontWeight: 900, borderRadius: 14 },
},
},
};