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