feat(admin): image thumbnails in lists + template image/demo fields
- AdminThumb: reusable thumbnail (raster + SVG via <img>, dashed fallback on empty/broken) - AdminResource: ColumnDef gains type:"image" → renders thumbnails in tables - image thumbnail columns for categories, slides, home-events, internal routes; categories icon field now multiline (accepts raw SVG markup) - TemplatesAdmin: cover image / mini-demo / demo / full-demo upload fields (backed by existing container image/demo fields) + thumbnail column in the list Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
@@ -17,6 +18,7 @@ export interface FieldDef {
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "text" | "image";
|
||||
render?: (row: Record<string, unknown>) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -162,7 +164,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
<tr key={String(row[idKey] ?? i)} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||
{config.columns.map((c) => (
|
||||
<td key={c.key} className="px-4 py-3 text-gray-200">
|
||||
{c.render ? c.render(row) : formatCell(row[c.key])}
|
||||
{c.render ? c.render(row) : c.type === "image" ? <AdminThumb src={row[c.key]} /> : formatCell(row[c.key])}
|
||||
</td>
|
||||
))}
|
||||
{(config.canEdit || config.canDelete || config.rowActions) && (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Small image thumbnail for admin tables/forms. Renders raster + SVG via <img>.
|
||||
* Falls back to a dashed placeholder when empty or the URL fails to load.
|
||||
*/
|
||||
export function AdminThumb({
|
||||
src,
|
||||
alt = "",
|
||||
size = 40,
|
||||
rounded = "rounded-md",
|
||||
}: {
|
||||
src?: unknown;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
rounded?: string;
|
||||
}) {
|
||||
const url = typeof src === "string" ? src.trim() : "";
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!url || failed) {
|
||||
return (
|
||||
<div
|
||||
style={{ width: size, height: size }}
|
||||
className={`flex items-center justify-center border border-dashed border-[#262b40] bg-[#0c0e1a] text-[9px] text-gray-600 ${rounded}`}
|
||||
>
|
||||
{failed ? "✕" : "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
style={{ width: size, height: size }}
|
||||
className={`border border-[#262b40] bg-[#0c0e1a] object-cover ${rounded}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,17 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||
|
||||
interface Container {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
image?: string | null;
|
||||
demo?: string | null;
|
||||
mini_demo?: string | null;
|
||||
is_published?: boolean;
|
||||
is_premium?: boolean;
|
||||
is_mockup?: boolean;
|
||||
@@ -18,6 +24,7 @@ interface Ref { id: string; name: string; slug?: string }
|
||||
interface Detail extends Container {
|
||||
keywords?: string | null;
|
||||
news_text?: string | null;
|
||||
full_demo?: string | null;
|
||||
categories?: Ref[];
|
||||
tags?: Ref[];
|
||||
projects?: { id: string; name: string; aspect?: string | null; resolution?: string }[];
|
||||
@@ -34,11 +41,13 @@ const lbl = "mb-1 block text-xs font-medium text-gray-400";
|
||||
|
||||
interface FormState {
|
||||
slug: string; name: string; description: string; keywords: string; news_text: string;
|
||||
image: string; demo: string; full_demo: string; mini_demo: string;
|
||||
is_published: boolean; is_premium: boolean; is_mockup: boolean; primary_mode: string; sort: number;
|
||||
category_ids: string[]; tag_ids: string[];
|
||||
}
|
||||
const emptyForm: FormState = {
|
||||
slug: "", name: "", description: "", keywords: "", news_text: "",
|
||||
image: "", demo: "", full_demo: "", mini_demo: "",
|
||||
is_published: false, is_premium: false, is_mockup: false, primary_mode: "FLEXIBLE", sort: 0,
|
||||
category_ids: [], tag_ids: [],
|
||||
};
|
||||
@@ -105,7 +114,9 @@ export function TemplatesAdmin() {
|
||||
setProjects(d.projects ?? []);
|
||||
setForm({
|
||||
slug: d.slug, name: d.name, description: d.description ?? "", keywords: d.keywords ?? "",
|
||||
news_text: d.news_text ?? "", is_published: !!d.is_published, is_premium: !!d.is_premium,
|
||||
news_text: d.news_text ?? "", image: d.image ?? "", demo: d.demo ?? "",
|
||||
full_demo: d.full_demo ?? "", mini_demo: d.mini_demo ?? "",
|
||||
is_published: !!d.is_published, is_premium: !!d.is_premium,
|
||||
is_mockup: !!d.is_mockup, primary_mode: d.primary_mode ?? "FLEXIBLE", sort: d.sort ?? 0,
|
||||
category_ids: (d.categories ?? []).map((c) => c.id), tag_ids: (d.tags ?? []).map((t) => t.id),
|
||||
});
|
||||
@@ -151,18 +162,19 @@ export function TemplatesAdmin() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
|
||||
<th className="px-4 py-3">Name</th><th className="px-4 py-3">Slug</th>
|
||||
<th className="px-4 py-3">Image</th><th className="px-4 py-3">Name</th><th className="px-4 py-3">Slug</th>
|
||||
<th className="px-4 py-3">Status</th><th className="px-4 py-3">Mode</th>
|
||||
<th className="px-4 py-3">Sort</th><th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">Loading…</td></tr>
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-500">Loading…</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">No templates.</td></tr>
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-500">No templates.</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||
<td className="px-4 py-3"><AdminThumb src={r.image ?? r.mini_demo ?? r.demo} size={48} /></td>
|
||||
<td className="px-4 py-3 text-gray-200">{r.name}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{r.slug}</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -195,6 +207,12 @@ export function TemplatesAdmin() {
|
||||
<div><label className={lbl}>Slug *</label><input className={inp} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><label className={lbl}>Description</label><textarea className={`${inp} min-h-[80px]`} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={lbl}>Cover image</label><FileUploadField value={form.image} onChange={(u) => setForm({ ...form, image: u })} accept="image/*" /></div>
|
||||
<div><label className={lbl}>Mini demo (thumbnail/gif)</label><FileUploadField value={form.mini_demo} onChange={(u) => setForm({ ...form, mini_demo: u })} accept="image/*,video/*" /></div>
|
||||
<div><label className={lbl}>Demo (preview video)</label><FileUploadField value={form.demo} onChange={(u) => setForm({ ...form, demo: u })} accept="video/*,image/*" /></div>
|
||||
<div><label className={lbl}>Full demo</label><FileUploadField value={form.full_demo} onChange={(u) => setForm({ ...form, full_demo: u })} accept="video/*,image/*" /></div>
|
||||
</div>
|
||||
<div><label className={lbl}>Keywords (SEO)</label><input className={inp} value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} /></div>
|
||||
<div><label className={lbl}>News / announcement text</label><textarea className={`${inp} min-h-[50px]`} value={form.news_text} onChange={(e) => setForm({ ...form, news_text: e.target.value })} /></div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -43,6 +43,7 @@ export const categoriesConfig: ResourceConfig = {
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "image_url", label: "Image", type: "image" },
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "slug", label: "Slug" },
|
||||
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||
@@ -53,7 +54,7 @@ export const categoriesConfig: ResourceConfig = {
|
||||
{ key: "slug", label: "Slug", required: true },
|
||||
{ key: "description", label: "Description / content", type: "textarea" },
|
||||
{ key: "image_url", label: "Image", type: "image" },
|
||||
{ key: "icon", label: "Icon" },
|
||||
{ key: "icon", label: "Icon (SVG markup or icon name)", type: "textarea" },
|
||||
// SEO
|
||||
{ key: "meta_title", label: "SEO · Meta title" },
|
||||
{ key: "meta_description", label: "SEO · Meta description", type: "textarea" },
|
||||
@@ -170,6 +171,7 @@ export const slidesConfig: ResourceConfig = {
|
||||
basePath: "slides",
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "image", label: "Image", type: "image" },
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "slide_type", label: "Type" },
|
||||
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||
@@ -185,6 +187,7 @@ export const homeEventsConfig: ResourceConfig = {
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "image", label: "Image", type: "image" },
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "badge", label: "Badge" },
|
||||
{ key: "sort", label: "Sort" },
|
||||
@@ -243,6 +246,7 @@ export const routesConfig: ResourceConfig = {
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "image", label: "Image", type: "image" },
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "slug", label: "Slug" },
|
||||
{ key: "priority", label: "Priority" },
|
||||
|
||||
Reference in New Issue
Block a user