feat(applications): drag-to-reorder on the public site
Editors can now control the order applications appear in, the same way they already reorder Hero slides. - Application gains an `order Int @default(0)` column (additive migration 20260605120000_add_application_order, IF NOT EXISTS, safe for deploy) plus an (isActive, order) index. - New reorderApplications(orderedSlugs) server action — single $transaction renumbering, mirrors reorderHeroSlides. - HQ applications panel: rows are now draggable by a grip handle (HTML5 DnD, optimistic local reorder, persisted on drop, toast feedback). - All public-facing queries now order by [order asc, createdAt asc]: home ApplicationsDashboard + GlobalOperations, the footer apps list, and the HQ list itself. Existing rows default to 0 so current order is preserved until the editor drags something. Verified: production build compiles, TypeScript clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — adds a manual `order` column to Application so the
|
||||||
|
-- editor can drag-to-reorder applications on the public site (same pattern
|
||||||
|
-- as HeroSlide). Existing rows default to 0 and keep their creation order
|
||||||
|
-- as a tiebreaker. Safe for `migrate deploy`. Idempotent.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE "Application" ADD COLUMN IF NOT EXISTS "order" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Application_isActive_order_idx" ON "Application" ("isActive", "order");
|
||||||
@@ -89,6 +89,9 @@ model Application {
|
|||||||
dashboardMetricsJson String? @default("[]")
|
dashboardMetricsJson String? @default("[]")
|
||||||
isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas
|
isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas
|
||||||
|
|
||||||
|
// 🔥 NUEVO: Orden manual para drag-to-reorder en el frontend (como HeroSlide)
|
||||||
|
order Int @default(0)
|
||||||
|
|
||||||
// 🌍 MOTOR DE TRADUCCIONES
|
// 🌍 MOTOR DE TRADUCCIONES
|
||||||
translationsJson String? @default("{}")
|
translationsJson String? @default("{}")
|
||||||
|
|
||||||
@@ -97,6 +100,7 @@ model Application {
|
|||||||
|
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
@@index([category])
|
@@index([category])
|
||||||
|
@@index([isActive, order])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin
|
|||||||
shortDescription: true, heroDescription: true,
|
shortDescription: true, heroDescription: true,
|
||||||
dashboardMetricsJson: true, isActive: true, translationsJson: true
|
dashboardMetricsJson: true, isActive: true, translationsJson: true
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "asc" }
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }]
|
||||||
});
|
});
|
||||||
dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale));
|
dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function getApplications() {
|
|||||||
noStore();
|
noStore();
|
||||||
try {
|
try {
|
||||||
const apps = await prisma.application.findMany({
|
const apps = await prisma.application.findMany({
|
||||||
orderBy: { createdAt: "asc" }
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }]
|
||||||
});
|
});
|
||||||
return { success: true, apps };
|
return { success: true, apps };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -161,6 +161,24 @@ export async function deleteApplication(slug: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6b. REORDENAR APLICACIONES (drag-to-reorder, mismo patrón que HeroSlide)
|
||||||
|
// Recibe la lista de slugs en el nuevo orden y renumera el campo `order`
|
||||||
|
// en una sola transacción atómica.
|
||||||
|
export async function reorderApplications(orderedSlugs: string[]) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(
|
||||||
|
orderedSlugs.map((slug, idx) =>
|
||||||
|
prisma.application.update({ where: { slug }, data: { order: idx } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
revalidatePath("/hq-command/dashboard/applications");
|
||||||
|
revalidatePath("/[locale]", "layout");
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: "Failed to reorder applications." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto)
|
// 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto)
|
||||||
export async function seedInitialApplications() {
|
export async function seedInitialApplications() {
|
||||||
// ... Tu código actual de la semilla se queda exactamente igual ...
|
// ... Tu código actual de la semilla se queda exactamente igual ...
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles,
|
ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles,
|
||||||
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw,
|
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw,
|
||||||
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check
|
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check, GripVertical
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
|
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication, reorderApplications } from "./actions";
|
||||||
import { useHqUi } from "@/components/hq/Toast";
|
import { useHqUi } from "@/components/hq/Toast";
|
||||||
|
|
||||||
|
|
||||||
@@ -258,9 +258,29 @@ export default function ApplicationsManager() {
|
|||||||
const [sections, setSections] = useState<any[]>([]);
|
const [sections, setSections] = useState<any[]>([]);
|
||||||
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
|
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const [draggedSlug, setDraggedSlug] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
|
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
|
||||||
useEffect(() => { fetchApps(); }, []);
|
useEffect(() => { fetchApps(); }, []);
|
||||||
|
|
||||||
|
// Drag-to-reorder — same pattern as the Hero panel. Optimistic local
|
||||||
|
// reorder, then persist the new order to the DB.
|
||||||
|
const onDropApp = async (targetSlug: string) => {
|
||||||
|
if (!draggedSlug || draggedSlug === targetSlug) return;
|
||||||
|
const slugs = apps.map((a) => a.slug);
|
||||||
|
const from = slugs.indexOf(draggedSlug);
|
||||||
|
const to = slugs.indexOf(targetSlug);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
const reordered = [...slugs];
|
||||||
|
reordered.splice(from, 1);
|
||||||
|
reordered.splice(to, 0, draggedSlug);
|
||||||
|
setApps((prev) => reordered.map((s) => prev.find((a) => a.slug === s)!));
|
||||||
|
setDraggedSlug(null);
|
||||||
|
const res = await reorderApplications(reordered);
|
||||||
|
if (res?.error) { ui.toast(res.error, "error"); fetchApps(); }
|
||||||
|
else ui.toast("Order updated.", "success");
|
||||||
|
};
|
||||||
|
|
||||||
const openEditModal = (app: any) => {
|
const openEditModal = (app: any) => {
|
||||||
setEditingApp(app); setActiveTab("basic");
|
setEditingApp(app); setActiveTab("basic");
|
||||||
try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); }
|
try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); }
|
||||||
@@ -299,7 +319,7 @@ export default function ApplicationsManager() {
|
|||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1>
|
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1>
|
||||||
<p className="text-[#86868B] mt-2">Manage the technical literature and general specifications of your application categories.</p>
|
<p className="text-[#86868B] mt-2">Manage the technical literature and specifications. Drag rows by the handle to reorder how applications appear on the site.</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
|
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,14 +328,24 @@ export default function ApplicationsManager() {
|
|||||||
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
|
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
|
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold w-10"></th><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr><td colSpan={4} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
|
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
|
||||||
|
) : apps.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]">No applications yet.</td></tr>
|
||||||
) : apps.map((app) => {
|
) : apps.map((app) => {
|
||||||
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
|
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
|
||||||
return (
|
return (
|
||||||
<tr key={app.slug} className={`border-b border-white/5 transition-colors group ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}>
|
<tr
|
||||||
|
key={app.slug}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDraggedSlug(app.slug)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => onDropApp(app.slug)}
|
||||||
|
className={`border-b border-white/5 transition-colors group ${draggedSlug === app.slug ? 'opacity-40' : ''} ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}
|
||||||
|
>
|
||||||
|
<td className="p-6"><span className="cursor-grab text-[#86868B] hover:text-white inline-flex" title="Drag to reorder"><GripVertical size={16} /></span></td>
|
||||||
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
|
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
|
||||||
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
|
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
|
||||||
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
|
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default async function Footer({ locale }: { locale: string }) {
|
|||||||
try {
|
try {
|
||||||
const rawApps = await prisma.application.findMany({
|
const rawApps = await prisma.application.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
take: 4,
|
take: 4,
|
||||||
});
|
});
|
||||||
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
|
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
|
||||||
|
|||||||
Reference in New Issue
Block a user