From 1e49157a3a25b54f6839c1c1b7cc914f727e547b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Dec 2025 14:35:26 -0500 Subject: [PATCH] card --- src/components/Cards/CardRenderer.jsx | 195 ++++++++++++++++++++++ src/components/Posts/PostCardTemplate.jsx | 60 +++++++ src/theme/cardTokens.js | 85 ++++++++++ 3 files changed, 340 insertions(+) create mode 100644 src/components/Cards/CardRenderer.jsx create mode 100644 src/components/Posts/PostCardTemplate.jsx create mode 100644 src/theme/cardTokens.js diff --git a/src/components/Cards/CardRenderer.jsx b/src/components/Cards/CardRenderer.jsx new file mode 100644 index 0000000..d1652a6 --- /dev/null +++ b/src/components/Cards/CardRenderer.jsx @@ -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 {text}; + return ( +
+ {text} +
+ ); +} + +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 ( +
+ +
+ ); + } + + if (layer.type === "chip") { + const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text; + return ( +
+ {String(value ?? "")} +
+ ); + } + + if (layer.type === "image") { + const src = layer.bind ? getByPath(ctx, layer.bind) : undefined; + return ( +
+ ); + } + + if (layer.type === "button") { + const label = layer.bind ? getByPath(ctx, layer.bind) : layer.text; + return ( + + ); + } + + if (layer.type === "icon") { + const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text; + return
{String(value ?? "•")}
; + } + + 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 ( +
+ {spec.layers.map((layer) => ( + + ))} +
+ ); +} diff --git a/src/components/Posts/PostCardTemplate.jsx b/src/components/Posts/PostCardTemplate.jsx new file mode 100644 index 0000000..2d5298c --- /dev/null +++ b/src/components/Posts/PostCardTemplate.jsx @@ -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 ( +
+ { + if (action.type === "open_url" && value) window.open(value, "_blank"); + }} + className="" + /> + {onOpen && ( + + )} +
+ ); +} diff --git a/src/theme/cardTokens.js b/src/theme/cardTokens.js new file mode 100644 index 0000000..043149c --- /dev/null +++ b/src/theme/cardTokens.js @@ -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 }, + }, + }, +};