feat: news admin — collapsible cards, photo preview; user side — sort by date, show more
This commit is contained in:
+56
-52
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
||||
import { Upload, Loader2, X } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { NewsItem } from "@/types/content";
|
||||
|
||||
@@ -13,7 +14,7 @@ interface NewsData {
|
||||
items: NewsItem[];
|
||||
}
|
||||
|
||||
function ImageUploadField({
|
||||
function PhotoPreview({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
@@ -21,7 +22,6 @@ function ImageUploadField({
|
||||
onChange: (path: string) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -46,54 +46,48 @@ function ImageUploadField({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||
Изображение
|
||||
</label>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
|
||||
{value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
|
||||
<ImageIcon size={14} className="text-gold" />
|
||||
<span className="max-w-[200px] truncate">
|
||||
{value.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
|
||||
<Image
|
||||
src={value}
|
||||
alt="Превью"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 500px"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||
{uploading ? (
|
||||
<Loader2 size={20} className="animate-spin text-white" />
|
||||
) : (
|
||||
<>
|
||||
<Upload size={20} className="text-white" />
|
||||
<span className="text-[11px] text-white/80">Изменить</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
className="absolute top-2 right-2 rounded-lg bg-black/60 p-1.5 text-neutral-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={14} />
|
||||
)}
|
||||
Заменить
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||
<label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={16} />
|
||||
<>
|
||||
<Upload size={20} />
|
||||
<span>Загрузить изображение</span>
|
||||
</>
|
||||
)}
|
||||
{uploading ? "Загрузка..." : "Загрузить изображение"}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -115,6 +109,18 @@ export default function NewsEditorPage() {
|
||||
label="Новости"
|
||||
items={data.items}
|
||||
onChange={(items) => update({ ...data, items })}
|
||||
collapsible
|
||||
getItemTitle={(item) => {
|
||||
const title = item.title || "Без заголовка";
|
||||
if (item.date) {
|
||||
try {
|
||||
const d = new Date(item.date);
|
||||
const formatted = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${title} · ${formatted}`;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return title;
|
||||
}}
|
||||
renderItem={(item, _i, updateItem) => (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -138,18 +144,16 @@ export default function NewsEditorPage() {
|
||||
value={item.text}
|
||||
onChange={(v) => updateItem({ ...item, text: v })}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ImageUploadField
|
||||
value={item.image || ""}
|
||||
onChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||
/>
|
||||
<InputField
|
||||
label="Ссылка (необязательно)"
|
||||
value={item.link || ""}
|
||||
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
||||
placeholder="https://instagram.com/p/..."
|
||||
/>
|
||||
</div>
|
||||
<PhotoPreview
|
||||
value={item.image || ""}
|
||||
onChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||
/>
|
||||
<InputField
|
||||
label="Ссылка (необязательно)"
|
||||
value={item.link || ""}
|
||||
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
||||
placeholder="https://instagram.com/p/..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
createItem={(): NewsItem => ({
|
||||
|
||||
Reference in New Issue
Block a user