/* global React, CrownMark */
const { useState, useEffect, useRef, useCallback } = React;
// =============================================================================
// SUPABASE CLIENT
// =============================================================================
const SUPABASE_URL = "https://okdickifdjxqbezxbkhd.supabase.co";
const SUPABASE_KEY = "sb_publishable_qGLmJK_HkbI77zkaELoqwQ_ja0F-XgQ";
const supa = (window.supabase && window.supabase.createClient)
? window.supabase.createClient(SUPABASE_URL, SUPABASE_KEY, {
auth: { persistSession: true, autoRefreshToken: true, storageKey: "rdc-auth" }
})
: null;
if (!supa) {
console.warn("Supabase client no se pudo inicializar. Revisar que supabase-js esté cargado.");
}
// =============================================================================
// STORE — Supabase-backed reactive hook
// =============================================================================
const STORE_KEYS = { blog: "blog", videos: "videos", fotos: "fotos" };
// Schema mappers (DB usa snake_case; el código usa camelCase para videos.youtube_id)
const MAPPERS = {
blog: { fromDb: r => r, toDb: r => r },
videos: {
fromDb: r => ({ ...r, youtubeId: r.youtube_id }),
toDb: r => { const { youtubeId, ...rest } = r; return { ...rest, youtube_id: youtubeId }; },
},
fotos: { fromDb: r => r, toDb: r => r },
};
const ORDER_BY = { blog: "date", videos: "created_at", fotos: "created_at" };
// Defaults — usados como semilla inicial y mientras Supabase responde
const DEFAULT_BLOG = [
{ title: "Vuelve el torneo 3x3 a La Pintana", excerpt: "Después de dos años, volvemos a la cancha donde todo empezó.", body: "Inscripciones abiertas hasta el 30 de junio.", cover: "assets/court-mockup.jpeg", featured: true, date: "2026-05-15", category: "Torneos" },
{ title: "Drop 02 — Camo Pack disponible", excerpt: "La estampa más icónica del street chileno.", body: "Edición limitada de 200 unidades.", cover: "assets/shorts-camo.jpeg", featured: true, date: "2026-05-08", category: "Merch" },
{ title: "Clínica gratuita en Cerro Navia", excerpt: "Joaco 'Sniper' Núñez dicta una clínica para sub-15.", body: "Cupo limitado a 30 participantes.", cover: "assets/squad-sunset.jpeg", featured: false, date: "2026-04-28", category: "Comunidad" },
];
const DEFAULT_VIDEOS = [
{ youtubeId: "dQw4w9WgXcQ", thumb: "assets/court-mockup.jpeg", title: "Mixtape Verano 2024", duration: "5:21", size: "wide" },
{ youtubeId: "dQw4w9WgXcQ", thumb: "assets/squad-sunset.jpeg", title: "Torneo La Pintana", duration: "3:42", size: "" },
];
const DEFAULT_FOTOS = [
{ src: "assets/court-mockup.jpeg", title: "La Cancha", subtitle: "Mockup oficial", size: "wide" },
{ src: "assets/squad-sunset.jpeg", title: "El Equipo", subtitle: "Documental", size: "" },
{ src: "assets/king-painting.jpeg", title: "El Rey", subtitle: "Editorial", size: "" },
{ src: "assets/king-blue-shorts.jpeg", title: "Crossover", subtitle: "Ilustración", size: "" },
{ src: "assets/orange-stance.jpeg", title: "Defensa", subtitle: "Movimiento", size: "" },
];
const _initialData = {};
const useStore = (table, defaults) => {
const [data, setData] = useState(() => _initialData[table] || defaults);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
if (!supa) { setLoading(false); return; }
const { data: rows, error } = await supa
.from(table)
.select("*")
.order(ORDER_BY[table], { ascending: false });
if (error) { setError(error.message); setLoading(false); return; }
const mapped = rows.map(MAPPERS[table].fromDb);
_initialData[table] = mapped; // cache for next mount
setData(mapped);
setLoading(false);
}, [table]);
useEffect(() => {
refresh();
if (!supa) return;
// Cross-instance refresh: cuando una instancia escribe, todas se enteran.
const onLocalChange = (e) => {
if (e.detail && e.detail.table === table) refresh();
};
window.addEventListener("rdc:supabase-change", onLocalChange);
// Realtime via Supabase (si está habilitado en el proyecto).
const channelName = `rt-${table}-${Math.random().toString(36).slice(2, 8)}`;
const ch = supa.channel(channelName);
ch.on("postgres_changes", { event: "*", schema: "public", table }, () => refresh());
ch.subscribe();
return () => {
window.removeEventListener("rdc:supabase-change", onLocalChange);
supa.removeChannel(ch);
};
}, [table, refresh]);
const notify = () => window.dispatchEvent(new CustomEvent("rdc:supabase-change", { detail: { table } }));
const add = useCallback(async (item) => {
if (!supa) return null;
const { id, created_at, ...rest } = item;
const { data: row, error } = await supa.from(table).insert(MAPPERS[table].toDb(rest)).select().single();
if (error) { alert("Error al crear: " + error.message); return null; }
notify();
return MAPPERS[table].fromDb(row);
}, [table]);
const update = useCallback(async (id, patch) => {
if (!supa) return null;
const payload = MAPPERS[table].toDb(patch);
delete payload.id; delete payload.created_at;
const { data: row, error } = await supa.from(table).update(payload).eq("id", id).select().single();
if (error) { alert("Error al actualizar: " + error.message); return null; }
notify();
return MAPPERS[table].fromDb(row);
}, [table]);
const remove = useCallback(async (id) => {
if (!supa) return;
const { error } = await supa.from(table).delete().eq("id", id);
if (error) alert("Error al eliminar: " + error.message);
else notify();
}, [table]);
return [data, { add, update, remove, refresh, loading, error }];
};
// =============================================================================
// IMAGE UPLOAD — Supabase Storage
// =============================================================================
async function uploadImage(file) {
if (!supa) { alert("Supabase no inicializado."); return null; }
if (!file) return null;
if (file.size > 5 * 1024 * 1024) { alert("La imagen supera 5MB."); return null; }
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
const path = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const { error } = await supa.storage.from("media").upload(path, file, {
cacheControl: "31536000",
upsert: false,
contentType: file.type || `image/${ext}`,
});
if (error) { alert("Error subiendo: " + error.message); return null; }
const { data } = supa.storage.from("media").getPublicUrl(path);
return data.publicUrl;
}
// =============================================================================
// PUBLIC — Novedades section
// =============================================================================
const formatDate = (iso) => {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const months = ["Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"];
return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
};
const Novedades = ({ onOpenPost }) => {
const [posts] = useStore(STORE_KEYS.blog, DEFAULT_BLOG);
const featured = posts.filter(p => p.featured).slice(0, 2);
if (featured.length === 0) return null;
return (
{featured.map((p, i) => (
onOpenPost && onOpenPost(p)}
className="group relative overflow-hidden bg-black border border-white/10 hover:border-[#FF6D0D]/60 transition-all cursor-pointer"
style={{ animation: `fadeUp 0.6s ${i * 0.1}s both` }}>
{p.category}
{formatDate(p.date)}
{Math.max(1, Math.ceil((p.body || "").length / 800))} min lectura
{p.title}
{p.excerpt}
))}
);
};
const BlogReader = ({ post, onClose }) => {
if (!post) return null;
return (
e.stopPropagation()} className="relative bg-[#0A0807] border border-white/10 w-full max-w-3xl my-8" style={{ animation: "pop 0.4s cubic-bezier(.34,1.56,.64,1)" }}>
{post.category}
{formatDate(post.date)}
{post.title}
{post.excerpt}
{post.body}
);
};
// =============================================================================
// ADMIN — Login (Supabase Auth) + Panel
// =============================================================================
const AdminGate = () => {
const [open, setOpen] = useState(false);
const [session, setSession] = useState(null);
const [sessionReady, setSessionReady] = useState(false);
useEffect(() => {
if (!supa) { setSessionReady(true); return; }
supa.auth.getSession().then(({ data }) => { setSession(data.session); setSessionReady(true); });
const { data: sub } = supa.auth.onAuthStateChange((_e, s) => setSession(s));
return () => sub.subscription.unsubscribe();
}, []);
useEffect(() => {
const check = () => { if (window.location.hash === "#admin") setOpen(true); };
check();
window.addEventListener("hashchange", check);
const onTrigger = () => setOpen(true);
window.addEventListener("rdc:open-admin", onTrigger);
return () => {
window.removeEventListener("hashchange", check);
window.removeEventListener("rdc:open-admin", onTrigger);
};
}, []);
const close = () => {
setOpen(false);
if (window.location.hash === "#admin") {
history.replaceState(null, "", window.location.pathname + window.location.search);
}
};
if (!open) return null;
if (!sessionReady) return null;
if (!session) return ;
return { await supa.auth.signOut(); }} />;
};
const AdminLogin = ({ onClose }) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
if (!supa) { setErr("Supabase no está conectado."); return; }
setBusy(true); setErr("");
const { error } = await supa.auth.signInWithPassword({ email: email.trim(), password });
setBusy(false);
if (error) { setErr(error.message); return; }
// session listener triggers re-render
};
return (
);
};
const AdminPanel = ({ onClose, onLogout }) => {
const [tab, setTab] = useState("blog");
const [blog, blogOps] = useStore(STORE_KEYS.blog, []);
const [videos, videosOps] = useStore(STORE_KEYS.videos, []);
const [fotos, fotosOps] = useStore(STORE_KEYS.fotos, []);
const tabs = [
{ k: "blog", label: "Novedades", count: blog.length, badge: blog.filter(p => p.featured).length + " destacadas" },
{ k: "videos", label: "Videos", count: videos.length },
{ k: "fotos", label: "Galería", count: fotos.length },
];
const totalRows = blog.length + videos.length + fotos.length;
const allLoading = blogOps.loading && videosOps.loading && fotosOps.loading;
const showSeed = !allLoading && totalRows === 0;
const seed = async () => {
if (!confirm("¿Insertar contenido de ejemplo? Solo recomendado si la base está vacía.")) return;
for (const p of DEFAULT_BLOG) await blogOps.add(p);
for (const v of DEFAULT_VIDEOS) await videosOps.add(v);
for (const f of DEFAULT_FOTOS) await fotosOps.add(f);
};
return (
Panel Admin
Reyes de la Calle · Supabase
{showSeed && (
)}
{tab === "blog" && }
{tab === "videos" && }
{tab === "fotos" && }
);
};
// =============================================================================
// FORM HELPERS
// =============================================================================
const TextField = ({ label, value, onChange, type = "text", placeholder, required, hint }) => (
);
const TextArea = ({ label, value, onChange, rows = 4, placeholder, required }) => (
);
const Toggle = ({ label, value, onChange, description }) => (
);
const ImageField = ({ label, value, onChange, hint }) => {
const fileRef = useRef(null);
const [uploading, setUploading] = useState(false);
const handleFile = async (file) => {
if (!file) return;
setUploading(true);
const url = await uploadImage(file);
if (url) onChange(url);
setUploading(false);
};
return (
{label}
{value ?

:
Sin imagen}
);
};
const Card = ({ children, onEdit, onDelete, featured }) => (
{featured && (
Destacada
)}
{children}
);
const FormShell = ({ title, onCancel, onSave, saveLabel = "Guardar", canSave = true, busy = false, hint = "", children }) => (
);
const PageHeader = ({ title, subtitle, onNew, newLabel = "+ Nuevo" }) => (
);
const EmptyState = ({ message }) => (
{message}
);
// =============================================================================
// BLOG ADMIN
// =============================================================================
const BlogAdmin = ({ items, ops }) => {
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const startNew = () => setEditing({
title: "", excerpt: "", body: "", cover: "", featured: false,
date: new Date().toISOString().slice(0, 10), category: "Comunidad", _new: true,
});
const save = async () => {
const { _new, ...post } = editing;
setBusy(true);
try {
if (_new) await ops.add(post);
else await ops.update(editing.id, post);
// Enforce máx 2 destacadas
if (post.featured) await enforceMax2Featured(items, editing.id || "_new", ops);
setEditing(null);
} finally { setBusy(false); }
};
const remove = async (id) => {
if (!confirm("¿Eliminar esta nota?")) return;
await ops.remove(id);
};
const toggleFeatured = async (post) => {
if (post.featured) {
await ops.update(post.id, { featured: false });
} else {
await ops.update(post.id, { featured: true });
await enforceMax2Featured(items, post.id, ops);
}
};
const featuredCount = items.filter(i => i.featured).length;
return (
{items.length === 0 ?
: (
{items.map(p => (
setEditing(p)} onDelete={() => remove(p.id)}>
{p.cover ?

:
sin imagen
}
{p.category}
{formatDate(p.date)}
{p.title || "(sin título)"}
{p.excerpt}
))}
)}
{editing && (() => {
const titleOk = !!(editing.title || "").trim();
const missing = !titleOk ? "Falta el título" : "";
return (
setEditing(null)}
onSave={save}
busy={busy}
canSave={titleOk}
hint={missing}>
setEditing({ ...editing, title: v })} required />
setEditing({ ...editing, category: v })} placeholder="Torneos, Merch, Comunidad..." />
setEditing({ ...editing, date: v })} />
setEditing({ ...editing, cover: v })} hint="Sube un archivo (≤5MB) o pega URL. Se guarda en tu bucket de Supabase." />
);
})()}
);
};
async function enforceMax2Featured(items, justUpdatedId, ops) {
const featured = items.filter(p => p.featured && p.id !== justUpdatedId);
// Si ya hay 2 o más además del que acabamos de marcar, demotear las más viejas
if (featured.length >= 2) {
const toDemote = [...featured].sort((a, b) => (a.date || "").localeCompare(b.date || "")).slice(0, featured.length - 1);
for (const p of toDemote) await ops.update(p.id, { featured: false });
}
}
// =============================================================================
// VIDEOS ADMIN
// =============================================================================
function extractYoutubeId(input) {
if (!input) return "";
const m = input.match(/(?:youtu\.be\/|v=|\/shorts\/|\/embed\/)([A-Za-z0-9_-]{11})/);
return m ? m[1] : input.trim();
}
const VideosAdmin = ({ items, ops }) => {
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const startNew = () => setEditing({ youtubeId: "", thumb: "", title: "", duration: "", size: "", _new: true });
const save = async () => {
const { _new, ...v } = editing;
if (!v.youtubeId) { alert("Necesitás un ID de YouTube válido."); return; }
if (!v.thumb) v.thumb = `https://img.youtube.com/vi/${v.youtubeId}/maxresdefault.jpg`;
setBusy(true);
try {
if (_new) await ops.add(v);
else await ops.update(editing.id, v);
setEditing(null);
} finally { setBusy(false); }
};
const remove = async (id) => {
if (!confirm("¿Eliminar este video?")) return;
await ops.remove(id);
};
return (
{items.length === 0 ?
: (
{items.map(v => (
setEditing(v)} onDelete={() => remove(v.id)}>
{v.thumb ?

:
sin thumb
}
{v.duration &&
{v.duration}
}
{v.title || "(sin título)"}
YT: {v.youtubeId || "—"}
{v.size === "wide" &&
Tamaño 2×2}
))}
)}
{editing && (
setEditing(null)} onSave={save} busy={busy} canSave={!!(editing.title || "").trim() && !!(editing.youtubeId || "").trim()}>
setEditing({ ...editing, title: v })} required />
setEditing({ ...editing, youtubeId: extractYoutubeId(v) })} required hint="Pega la URL completa o solo el ID (11 caracteres)." />
setEditing({ ...editing, duration: v })} placeholder="3:42" />
Tamaño
setEditing({ ...editing, thumb: v })} hint="Si lo dejas vacío, usamos el thumb automático de YouTube." />
)}
);
};
// =============================================================================
// FOTOS ADMIN
// =============================================================================
const FotosAdmin = ({ items, ops }) => {
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const startNew = () => setEditing({ src: "", title: "", subtitle: "", size: "", _new: true });
const save = async () => {
const { _new, ...f } = editing;
if (!f.src) { alert("Necesitás una imagen."); return; }
setBusy(true);
try {
if (_new) await ops.add(f);
else await ops.update(editing.id, f);
setEditing(null);
} finally { setBusy(false); }
};
const remove = async (id) => {
if (!confirm("¿Eliminar esta foto?")) return;
await ops.remove(id);
};
return (
{items.length === 0 ?
: (
{items.map(f => (
setEditing(f)} onDelete={() => remove(f.id)}>
{f.src ?

:
sin imagen
}
{f.size === "wide" &&
2×2
}
{f.title || "(sin título)"}
{f.subtitle || "—"}
))}
)}
{editing && (
setEditing(null)} onSave={save} busy={busy} canSave={!!editing.src && !!(editing.title || "").trim()}>
setEditing({ ...editing, src: v })} hint="Sube un archivo (≤5MB) o pega URL." />
setEditing({ ...editing, title: v })} required />
setEditing({ ...editing, subtitle: v })} placeholder="Editorial, Documental, Movimiento..." />
Tamaño en grilla
)}
);
};
Object.assign(window, {
STORE_KEYS,
DEFAULT_BLOG,
DEFAULT_VIDEOS,
DEFAULT_FOTOS,
useStore,
uploadImage,
Novedades,
BlogReader,
AdminGate,
});