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:
soroush.asadi
2026-06-02 23:23:52 +03:30
parent 24aa4c51a4
commit b47314fcab
4 changed files with 76 additions and 6 deletions
+3 -1
View File
@@ -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) && (
+46
View File
@@ -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}`}
/>
);
}
+22 -4
View File
@@ -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">
+5 -1
View File
@@ -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" },