eldouma-graphics / src /components /BulkStylePanel.tsx
Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
6.25 kB
import { useRef } from "react";
import { X } from "lucide-react";
import { useStore } from "../store";
import { MG_THEME_LIST, MG_ACCENTS } from "../remotion/mg/templates";
import { MG_BACKGROUNDS, MG_ANIMATIONS, MG_FONTS, MG_FONTS_AR } from "../remotion/mg/catalog.mjs";
import { AccentControls } from "./SceneInspector";
// Bulk styling for a multi-selection of scenes. Mirrors the SceneInspector look controls,
// but every control writes the chosen property onto ALL selected scenes at once (one undo step
// per change) via applyLook({ <prop>: value }, selectedIds).
export function BulkStylePanel() {
const selectedIds = useStore((s) => s.selectedIds);
const clearSelection = useStore((s) => s.clearSelection);
const applyLook = useStore((s) => s.applyLook);
const pushHistory = useStore((s) => s.pushHistory);
const updateMotion = useStore((s) => s.updateMotion);
const motion = useStore((s) => s.motion); // stable ref; filter in render (a selector that .filter()s returns a NEW array each call → infinite re-render loop)
const lang = useStore((s) => s.lang);
// only template scenes are styleable; keep store order
const scenes = motion.filter((m) => m.template && selectedIds.includes(m.id));
const n = scenes.length;
// live color drag → snapshot once on pointerdown, then live-write to every selected scene (no per-event history)
const armed = useRef(false);
const accentArm = () => { armed.current = true; };
const accentLive = (c: string) => { if (armed.current) { pushHistory(); armed.current = false; } for (const m of scenes) updateMotion(m.id, { accent: c }); };
// a property is "active" only when every selected scene shares the same value (else: mixed)
const shared = (get: (m: (typeof scenes)[number]) => string | undefined, dflt: string): string | null => {
if (!n) return dflt;
const first = get(scenes[0]) || dflt;
return scenes.every((m) => (get(m) || dflt) === first) ? first : null;
};
const theme = shared((m) => m.theme, "dark");
const accent = shared((m) => m.accent, MG_ACCENTS[0]);
const bg = shared((m) => m.bg, "mesh");
const anim = shared((m) => m.anim, "rise");
const font = shared((m) => m.font, "editorial");
const set = (patch: Record<string, string>) => applyLook(patch, selectedIds);
const eq = (a: string | null, b: string) => a != null && a.toLowerCase() === b.toLowerCase();
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="eyebrow !text-fg">Styling {n} scene{n === 1 ? "" : "s"}</span>
<span className="rounded bg-[var(--color-tl-motion)] px-1.5 py-0.5 text-[10px] font-medium text-white">Bulk</span>
<div className="flex-1" />
<button onClick={clearSelection} title="Clear selection" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-white/5 hover:text-fg"><X size={12} /> Clear</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-3">
<div className="text-[11px] text-fg-subtle">Changes apply to all {n} selected scenes.</div>
{/* theme */}
<div>
<div className="eyebrow mb-1.5">Theme</div>
<div className="flex flex-wrap gap-1.5">
{MG_THEME_LIST.map((t) => (
<button key={t.id} onClick={() => set({ theme: t.id })} title={t.label}
className={"h-8 w-8 rounded-md border-2 " + (theme === t.id ? "border-primary" : "border-transparent")}
style={{ background: t.swatch }} />
))}
</div>
</div>
{/* accent — presets + custom color, identical to the inspector */}
<div>
<div className="eyebrow mb-1.5">Accent {accent == null && <span className="ml-1 text-fg-subtle">· mixed</span>}</div>
<AccentControls accent={accent || ""} onPick={(c) => set({ accent: c })} onArm={accentArm} onLive={accentLive} />
</div>
{/* background */}
<div>
<div className="eyebrow mb-1.5">Background</div>
<div className="grid grid-cols-6 gap-1.5">
{MG_BACKGROUNDS.map((bgo) => (
<button key={bgo.id} onClick={() => set({ bg: bgo.id })} title={`${bgo.label} — ${bgo.blurb}`}
className={"aspect-square overflow-hidden rounded-md border-2 " + (bg === bgo.id ? "border-primary" : "border-border/60 hover:border-primary/50")}
style={{ background: bgo.swatch }} />
))}
</div>
</div>
{/* animation */}
<div>
<div className="eyebrow mb-1.5">Animation</div>
<div className="flex flex-wrap gap-1.5">
{MG_ANIMATIONS.map((a) => (
<button key={a.id} onClick={() => set({ anim: a.id })} title={a.blurb}
className={"rounded-md border px-2 py-1 text-[11px] " + (anim === a.id ? "border-primary bg-primary/10 text-fg" : "border-border text-fg-muted hover:border-primary/50")}>{a.label}</button>
))}
</div>
</div>
{/* font */}
{(() => {
const ar = lang === "ar";
const fonts = ar ? MG_FONTS_AR : MG_FONTS;
return (
<div>
<div className="eyebrow mb-1.5 flex items-center gap-1.5">Font{ar && <span className="rounded bg-primary/15 px-1.5 py-px text-[9px] font-semibold text-primary">عربي</span>}</div>
<div className="grid grid-cols-3 gap-1.5">
{fonts.map((f) => (
<button key={f.id} onClick={() => set({ font: f.id })} title={f.blurb}
className={"rounded-md border px-2 py-1.5 text-left " + (eq(font, f.id) ? "border-primary bg-primary/10" : "border-border hover:border-primary/50")}>
<div className="truncate text-[18px] leading-none text-fg" style={{ fontFamily: f.preview, direction: ar ? "rtl" : undefined }}>{ar ? "أبجد" : "Ag"}</div>
<div className="mt-1 truncate text-[10px] text-fg-subtle">{f.label}</div>
</button>
))}
</div>
</div>
);
})()}
</div>
</div>
);
}