Make uploaded calendars live with drag-and-drop days and readable chips.
Posts persist to the tenant calendar document when moved; contrast on the month grid no longer uses dark text on dark fills. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
170
src/app/api/calendar/move/route.ts
Normal file
170
src/app/api/calendar/move/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import type { RrssPiece } from '@/components/RrssMonthView';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type PlanDoc = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
function asObject(content: unknown): Record<string, unknown> | null {
|
||||
let c = content;
|
||||
if (typeof c === 'string') {
|
||||
try {
|
||||
c = JSON.parse(c);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
return c as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function piecesOf(content: Record<string, unknown>): RrssPiece[] {
|
||||
return Array.isArray(content.pieces) ? (content.pieces as RrssPiece[]) : [];
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as {
|
||||
pieceId?: string;
|
||||
date?: string;
|
||||
snapshot?: RrssPiece;
|
||||
} | null;
|
||||
const pieceId = String(body?.pieceId || '').trim();
|
||||
const date = String(body?.date || '').slice(0, 10);
|
||||
if (!pieceId || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Falta pieceId o una fecha válida.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
const list = await fetch(
|
||||
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||
ctx.tenantId
|
||||
)}&type=eq.calendar&select=id,title,content`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const docs = (list.ok ? await list.json() : []) as PlanDoc[];
|
||||
|
||||
let source: { doc: PlanDoc; plan: Record<string, unknown>; index: number } | null =
|
||||
null;
|
||||
for (const doc of docs) {
|
||||
const plan = asObject(doc.content);
|
||||
if (!plan) continue;
|
||||
const pieces = piecesOf(plan);
|
||||
const index = pieces.findIndex((p) => p.id === pieceId);
|
||||
if (index >= 0) {
|
||||
source = { doc, plan, index };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const ym = date.slice(0, 7);
|
||||
let targetDoc: PlanDoc | null = source?.doc || null;
|
||||
let targetPlan: Record<string, unknown> | null = source?.plan || null;
|
||||
|
||||
if (!targetDoc || (source && String(source.plan.month || '') !== ym)) {
|
||||
for (const doc of docs) {
|
||||
const plan = asObject(doc.content);
|
||||
if (!plan) continue;
|
||||
if (String(plan.month || '') === ym) {
|
||||
targetDoc = doc;
|
||||
targetPlan = plan;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetDoc || !targetPlan) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No hay un calendario guardado para ese mes.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const targetPieces = piecesOf(targetPlan);
|
||||
|
||||
if (source && source.doc.id === targetDoc.id) {
|
||||
targetPieces[source.index] = { ...targetPieces[source.index], date };
|
||||
} else if (source) {
|
||||
const moved = { ...piecesOf(source.plan)[source.index], date };
|
||||
piecesOf(source.plan).splice(source.index, 1);
|
||||
source.plan.pieces = piecesOf(source.plan);
|
||||
const srcPatch = await fetch(
|
||||
`${base}/rest/v1/documents?id=eq.${source.doc.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ content: source.plan }),
|
||||
}
|
||||
);
|
||||
if (!srcPatch.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No se pudo actualizar el mes de origen.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
targetPieces.push(moved);
|
||||
} else if (body?.snapshot) {
|
||||
targetPieces.push({ ...body.snapshot, date, id: pieceId });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'No se encontró esa pieza en el calendario.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
targetPlan.pieces = targetPieces.sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date))
|
||||
);
|
||||
|
||||
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${targetDoc.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ content: targetPlan }),
|
||||
});
|
||||
if (!patch.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (await patch.text()).slice(0, 240) || 'No se pudo guardar.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, pieceId, date });
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export default async function CalendarioPage({
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
{ctx.label}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-2 max-w-xl">
|
||||
<p className="text-sm text-gray-600 mt-2 max-w-xl">
|
||||
Calendario editorial de {ctx.label}. No se publica en redes: solo el
|
||||
cliente (y la agencia en su contexto) lo ve aquí.
|
||||
</p>
|
||||
@@ -154,13 +154,13 @@ export default async function CalendarioPage({
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/dashboard/calendario?view=archive"
|
||||
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#16204A] text-gray-900 text-sm font-bold hover:bg-[#1a2a63]"
|
||||
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#16204A] text-white text-sm font-bold hover:bg-[#1a2a63]"
|
||||
>
|
||||
<Instagram className="w-4 h-4 mr-2" /> IG / FB
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/calendario?view=month&month=2026-10&tab=linkedin"
|
||||
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#2071CF] text-gray-900 text-sm font-bold hover:bg-[#1a5fb8]"
|
||||
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#2071CF] text-white text-sm font-bold hover:bg-[#1a5fb8]"
|
||||
>
|
||||
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
|
||||
</Link>
|
||||
|
||||
@@ -181,8 +181,8 @@ export default function RrssArchiveYear({
|
||||
Calendario IG / FB {year}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-2 max-w-xl leading-relaxed">
|
||||
Archivo mensual de contenidos. Los días redondeados y coloreados
|
||||
marcan el tipo de publicación (misma paleta que en cada mes).
|
||||
Archivo mensual de contenidos. Los días coloreados marcan el tipo
|
||||
de publicación.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
GripVertical,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
MessageSquare,
|
||||
@@ -16,7 +17,6 @@ import CalendarDayZoom, {
|
||||
type SocialLite,
|
||||
} from '@/components/CalendarDayZoom';
|
||||
import {
|
||||
pipelineMediaUrl,
|
||||
pipelinePublishDay,
|
||||
type PipelineCalendarRow,
|
||||
} from '@/lib/pipeline-calendar';
|
||||
@@ -112,25 +112,30 @@ function colorFor(piece: RrssPiece): string {
|
||||
function DualBadge({ dual }: { dual?: Dual }) {
|
||||
if (!dual) {
|
||||
return (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-white/10 text-gray-900/50">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-gray-300 text-gray-700 bg-white">
|
||||
Pendiente
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (dual.both) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-200">
|
||||
<ShieldCheck className="w-3 h-3" /> Doble OK
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-200 border border-amber-500/25">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-50 text-amber-900 border border-amber-200">
|
||||
{dual.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function chipTone(piece: RrssPiece): { accent: string; bg: string } {
|
||||
const accent = colorFor(piece);
|
||||
return { accent, bg: `${accent}22` };
|
||||
}
|
||||
|
||||
function pieceToRow(p: RrssPiece): PipelineCalendarRow {
|
||||
return {
|
||||
id: p.pipeline_id || p.id,
|
||||
@@ -157,7 +162,7 @@ export default function RrssMonthView({
|
||||
canCollaborate?: boolean;
|
||||
initialPlatform?: 'all' | string;
|
||||
}) {
|
||||
const pieces = plan.pieces || [];
|
||||
const [livePieces, setLivePieces] = useState<RrssPiece[]>(plan.pieces || []);
|
||||
const [platform, setPlatform] = useState<'all' | string>(initialPlatform);
|
||||
const [selected, setSelected] = useState<RrssPiece | null>(null);
|
||||
const [dayZoom, setDayZoom] = useState<string | null>(null);
|
||||
@@ -169,6 +174,16 @@ export default function RrssMonthView({
|
||||
const [party, setParty] = useState<'agency' | 'client'>('client');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dragOverDay, setDragOverDay] = useState<string | null>(null);
|
||||
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
||||
const draggingId = React.useRef<string | null>(null);
|
||||
const suppressClick = React.useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLivePieces(plan.pieces || []);
|
||||
}, [plan]);
|
||||
|
||||
const pieces = livePieces;
|
||||
|
||||
const platforms = useMemo(() => {
|
||||
const set = new Set(pieces.map((p) => p.platform));
|
||||
@@ -309,7 +324,13 @@ export default function RrssMonthView({
|
||||
: false;
|
||||
|
||||
function rowsForDay(key: string): PipelineCalendarRow[] {
|
||||
const fromPipe = pipeItems.filter((it) => pipelinePublishDay(it) === key);
|
||||
const fromPipe = pipeItems.filter((it) => {
|
||||
const linked = livePieces.find(
|
||||
(p) => p.pipeline_id === it.id || p.id === `pipe-${it.id}`
|
||||
);
|
||||
if (linked) return linked.date.slice(0, 10) === key;
|
||||
return pipelinePublishDay(it) === key;
|
||||
});
|
||||
const fromPlan = (byDate.get(key) || [])
|
||||
.map((p) => pieceToRow(p))
|
||||
.filter((r) => r.cover_path || r.caption || r.notes);
|
||||
@@ -325,6 +346,10 @@ export default function RrssMonthView({
|
||||
}
|
||||
|
||||
function openDay(key: string, piece?: RrssPiece) {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
const rows = rowsForDay(key);
|
||||
if (rows.length) {
|
||||
setDayZoom(key);
|
||||
@@ -333,6 +358,33 @@ export default function RrssMonthView({
|
||||
if (piece) setSelected(piece);
|
||||
}
|
||||
|
||||
async function movePiece(pieceId: string, toDate: string) {
|
||||
const piece = livePieces.find((p) => p.id === pieceId);
|
||||
if (!piece || piece.date.slice(0, 10) === toDate) return;
|
||||
const prev = livePieces;
|
||||
setLivePieces((list) =>
|
||||
list.map((p) => (p.id === pieceId ? { ...p, date: toDate } : p))
|
||||
);
|
||||
setMoveMsg(null);
|
||||
try {
|
||||
const r = await fetch('/api/calendar/move', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pieceId, date: toDate, snapshot: piece }),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setLivePieces(prev);
|
||||
setMoveMsg(j.error || 'No se pudo mover la pieza');
|
||||
return;
|
||||
}
|
||||
setMoveMsg(`Movida al ${toDate.slice(8, 10)}`);
|
||||
} catch {
|
||||
setLivePieces(prev);
|
||||
setMoveMsg('No se pudo mover la pieza');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hub crumbs + network filters */}
|
||||
@@ -370,8 +422,8 @@ export default function RrssMonthView({
|
||||
onClick={() => setPlatform('instagram')}
|
||||
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
|
||||
platform === 'instagram' || platform === 'facebook'
|
||||
? 'bg-[#16204A] text-gray-900 ring-2 ring-sky-400/40'
|
||||
: 'bg-[#16204A]/80 text-gray-900/90 hover:bg-[#1a2a63]'
|
||||
? 'bg-[#16204A] text-white ring-2 ring-sky-400/40'
|
||||
: 'bg-[#16204A] text-white/90 hover:bg-[#1a2a63]'
|
||||
}`}
|
||||
>
|
||||
<Instagram className="w-4 h-4 mr-2" /> IG / FB
|
||||
@@ -381,8 +433,8 @@ export default function RrssMonthView({
|
||||
onClick={() => setPlatform('linkedin')}
|
||||
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
|
||||
platform === 'linkedin'
|
||||
? 'bg-[#2071CF] text-gray-900 ring-2 ring-sky-300/50'
|
||||
: 'bg-[#2071CF]/80 text-gray-900/90 hover:bg-[#1a5fb8]'
|
||||
? 'bg-[#2071CF] text-white ring-2 ring-sky-300/50'
|
||||
: 'bg-[#2071CF] text-white hover:bg-[#1a5fb8]'
|
||||
}`}
|
||||
>
|
||||
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
|
||||
@@ -394,10 +446,10 @@ export default function RrssMonthView({
|
||||
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-300/90">
|
||||
{plan.hero.eyebrow || '4VENTURES · PLAN OPERATIVO'}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl md:text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
<h2 className="mt-2 text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||
{plan.hero.title}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-slate-300 max-w-2xl leading-relaxed">
|
||||
<p className="mt-3 text-sm text-slate-200 max-w-2xl leading-relaxed">
|
||||
{plan.hero.lede}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
@@ -457,7 +509,7 @@ export default function RrssMonthView({
|
||||
<p className="text-sm font-semibold text-gray-900">{r.label}</p>
|
||||
<p className="text-[11px] text-gray-500">{r.detail}</p>
|
||||
</div>
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${r.pct}%`, background: r.color }}
|
||||
@@ -493,7 +545,7 @@ export default function RrssMonthView({
|
||||
<p className="text-sm font-semibold text-gray-900">{r.label}</p>
|
||||
<p className="text-[11px] text-gray-500">{r.detail}</p>
|
||||
</div>
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${r.pct}%`, background: r.color }}
|
||||
@@ -536,15 +588,20 @@ export default function RrssMonthView({
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-1">
|
||||
El mes de un vistazo
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Clic en el día publicado para ver la creatividad real (preview +
|
||||
métricas). El pipeline es solo para revisión.
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Arrastra un post o story a otro día para reprogramarlo. Clic en el
|
||||
día abre el zoom.
|
||||
</p>
|
||||
{moveMsg ? (
|
||||
<p className="mb-3 text-xs font-medium text-gray-800 bg-gray-50 border border-gray-200 rounded-lg px-3 py-1.5">
|
||||
{moveMsg}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-7 gap-1.5 mb-1.5">
|
||||
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => (
|
||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[11px] font-bold text-gray-900 bg-[#16204A] rounded-lg py-2"
|
||||
className="text-center text-[12px] font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg py-2"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
@@ -554,58 +611,96 @@ export default function RrssMonthView({
|
||||
{Array.from({ length: startPad }).map((_, i) => (
|
||||
<div
|
||||
key={`pad-${i}`}
|
||||
className="min-h-[88px] rounded-lg border border-dashed border-gray-200 bg-transparent"
|
||||
className="min-h-[112px] rounded-lg border border-dashed border-gray-200 bg-gray-50/60"
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const key = `${plan.month}-${String(day).padStart(2, '0')}`;
|
||||
const dayPieces = byDate.get(key) || [];
|
||||
const pipeDay = pipeItems.filter(
|
||||
(it) => pipelinePublishDay(it) === key
|
||||
);
|
||||
const cover = pipeDay[0]
|
||||
? pipelineMediaUrl(pipeDay[0].cover_path)
|
||||
: dayPieces.find((p) => p.cover_path)
|
||||
? pipelineMediaUrl(
|
||||
dayPieces.find((p) => p.cover_path)!.cover_path!
|
||||
)
|
||||
: null;
|
||||
const over = dragOverDay === key;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={key}
|
||||
type="button"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverDay(key);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setDragOverDay((cur) => (cur === key ? null : cur));
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverDay(null);
|
||||
const id =
|
||||
e.dataTransfer.getData('text/plain') || draggingId.current;
|
||||
if (id) {
|
||||
suppressClick.current = true;
|
||||
void movePiece(id, key);
|
||||
}
|
||||
}}
|
||||
onClick={() => openDay(key, dayPieces[0])}
|
||||
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5 text-left hover:border-brand-400 hover:shadow-sm overflow-hidden"
|
||||
className={`min-h-[112px] rounded-lg border bg-white p-1.5 text-left cursor-pointer overflow-hidden ${
|
||||
over
|
||||
? 'border-brand-500 ring-2 ring-brand-200 bg-brand-50/40'
|
||||
: 'border-gray-200 hover:border-brand-400 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<p className="text-[11px] font-bold text-gray-600 mb-1">{day}</p>
|
||||
{cover ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="mb-1 h-10 w-full rounded object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{dayPieces.slice(0, cover ? 1 : 3).map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="block w-full rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
|
||||
style={{ background: colorFor(p) }}
|
||||
title={p.title}
|
||||
>
|
||||
{p.platform === 'linkedin' ? 'LI' : 'IG'} ·{' '}
|
||||
{p.title.replace(/^[^·]+·\s*/, '').slice(0, 42)}
|
||||
</span>
|
||||
))}
|
||||
{dayPieces.length > (cover ? 1 : 3) && (
|
||||
<p className="text-[9px] text-gray-500">
|
||||
+{dayPieces.length - (cover ? 1 : 3)}
|
||||
<p className="text-[13px] font-bold text-gray-900 mb-1 tabular-nums">
|
||||
{day}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{dayPieces.map((p) => {
|
||||
const tone = chipTone(p);
|
||||
const kind =
|
||||
(p.kind || p.format || 'post').toLowerCase().includes('stor')
|
||||
? 'Story'
|
||||
: (p.format || 'Post').replace(/^./, (c) =>
|
||||
c.toUpperCase()
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
draggingId.current = p.id;
|
||||
e.dataTransfer.setData('text/plain', p.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
draggingId.current = null;
|
||||
setDragOverDay(null);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
openDay(key, p);
|
||||
}}
|
||||
className="flex items-start gap-0.5 w-full rounded-md px-1 py-1 text-left cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
background: tone.bg,
|
||||
borderLeft: `3px solid ${tone.accent}`,
|
||||
}}
|
||||
title={`${p.title} — arrastra para cambiar de día`}
|
||||
>
|
||||
<GripVertical className="w-3 h-3 mt-0.5 shrink-0 text-gray-500" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-wide text-gray-700">
|
||||
{p.platform === 'linkedin' ? 'LI' : 'IG'} · {kind}
|
||||
</span>
|
||||
<span className="block text-[11px] font-semibold leading-snug text-gray-900 line-clamp-2">
|
||||
{p.title.replace(/^[^·]+·\s*/, '')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -627,7 +722,7 @@ export default function RrssMonthView({
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded text-gray-900"
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded text-white"
|
||||
style={{ background: colorFor(p) }}
|
||||
>
|
||||
{p.platform === 'linkedin' ? (
|
||||
@@ -658,11 +753,11 @@ export default function RrssMonthView({
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900">{p.title}</p>
|
||||
{p.objective_metric && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Objetivo: {p.objective_metric}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2 line-clamp-2">
|
||||
<p className="text-sm text-gray-700 mt-2 line-clamp-2 leading-relaxed">
|
||||
{p.hook || p.body}
|
||||
</p>
|
||||
</div>
|
||||
@@ -710,10 +805,10 @@ export default function RrssMonthView({
|
||||
<p className="text-xs text-gray-400">{selected.meta_line}</p>
|
||||
)
|
||||
)}
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-amber-200 text-sm">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-amber-950 text-sm">
|
||||
{selected.hook}
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-900/40 p-3 text-gray-800 whitespace-pre-wrap text-xs leading-relaxed">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-gray-800 whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{selected.body}
|
||||
</div>
|
||||
{selected.cta && (
|
||||
|
||||
@@ -121,8 +121,18 @@ export function mergePipelineIntoPlans(
|
||||
for (const it of items) {
|
||||
const day = pipelinePublishDay(it);
|
||||
if (!day) continue;
|
||||
const ym = day.slice(0, 7);
|
||||
let plan = byMonth.get(ym);
|
||||
const ig = instagramUrlFromNotes(it.notes);
|
||||
const igKey = normPermalink(ig);
|
||||
|
||||
let existing: CalendarPieceLike | undefined;
|
||||
let plan = out.find((p) => p.pieces.some((x) => x.pipeline_id === it.id));
|
||||
if (plan) {
|
||||
existing = plan.pieces.find((p) => p.pipeline_id === it.id);
|
||||
}
|
||||
const ym = (existing?.date || day).slice(0, 7);
|
||||
if (!plan) {
|
||||
plan = byMonth.get(ym);
|
||||
}
|
||||
if (!plan) {
|
||||
const label = monthLabel(ym);
|
||||
plan = {
|
||||
@@ -140,15 +150,15 @@ export function mergePipelineIntoPlans(
|
||||
out.push(plan);
|
||||
byMonth.set(ym, plan);
|
||||
}
|
||||
const ig = instagramUrlFromNotes(it.notes);
|
||||
const igKey = normPermalink(ig);
|
||||
const existing = plan.pieces.find((p) => {
|
||||
if (!existing) {
|
||||
existing = plan.pieces.find((p) => {
|
||||
if ((p.date || '').slice(0, 10) !== day) return false;
|
||||
if (igKey && normPermalink(p.meta_line) === igKey) return true;
|
||||
const a = (p.title || '').toLowerCase().slice(0, 28);
|
||||
const b = (it.title || '').toLowerCase().slice(0, 28);
|
||||
return a && b && (a.includes(b) || b.includes(a));
|
||||
});
|
||||
}
|
||||
const slides = slidesOf(it.slide_paths);
|
||||
if (existing) {
|
||||
existing.pipeline_id = it.id;
|
||||
|
||||
Reference in New Issue
Block a user