card
This commit is contained in:
parent
5394a1282b
commit
1e49157a3a
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue