feat: news admin — collapsible cards, photo preview; user side — sort by date, show more
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
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 { adminFetch } from "@/lib/csrf";
|
||||||
import type { NewsItem } from "@/types/content";
|
import type { NewsItem } from "@/types/content";
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ interface NewsData {
|
|||||||
items: NewsItem[];
|
items: NewsItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageUploadField({
|
function PhotoPreview({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -21,7 +22,6 @@ function ImageUploadField({
|
|||||||
onChange: (path: string) => void;
|
onChange: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -46,54 +46,48 @@ function ImageUploadField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
<label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
|
||||||
Изображение
|
|
||||||
</label>
|
|
||||||
{value ? (
|
{value ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
|
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
|
||||||
<ImageIcon size={14} className="text-gold" />
|
<Image
|
||||||
<span className="max-w-[200px] truncate">
|
src={value}
|
||||||
{value.split("/").pop()}
|
alt="Превью"
|
||||||
</span>
|
fill
|
||||||
</div>
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange("")}
|
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} />
|
<X size={14} />
|
||||||
</button>
|
</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>
|
</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 ? (
|
{uploading ? (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Upload size={16} />
|
<>
|
||||||
|
<Upload size={20} />
|
||||||
|
<span>Загрузить изображение</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{uploading ? "Загрузка..." : "Загрузить изображение"}
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +109,18 @@ export default function NewsEditorPage() {
|
|||||||
label="Новости"
|
label="Новости"
|
||||||
items={data.items}
|
items={data.items}
|
||||||
onChange={(items) => update({ ...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) => (
|
renderItem={(item, _i, updateItem) => (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -138,18 +144,16 @@ export default function NewsEditorPage() {
|
|||||||
value={item.text}
|
value={item.text}
|
||||||
onChange={(v) => updateItem({ ...item, text: v })}
|
onChange={(v) => updateItem({ ...item, text: v })}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<PhotoPreview
|
||||||
<ImageUploadField
|
value={item.image || ""}
|
||||||
value={item.image || ""}
|
onChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||||
onChange={(v) => updateItem({ ...item, image: v || undefined })}
|
/>
|
||||||
/>
|
<InputField
|
||||||
<InputField
|
label="Ссылка (необязательно)"
|
||||||
label="Ссылка (необязательно)"
|
value={item.link || ""}
|
||||||
value={item.link || ""}
|
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
||||||
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
placeholder="https://instagram.com/p/..."
|
||||||
placeholder="https://instagram.com/p/..."
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
createItem={(): NewsItem => ({
|
createItem={(): NewsItem => ({
|
||||||
|
|||||||
@@ -106,12 +106,19 @@ function CompactArticle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INITIAL_VISIBLE = 4;
|
||||||
|
|
||||||
export function News({ data }: NewsProps) {
|
export function News({ data }: NewsProps) {
|
||||||
const [selected, setSelected] = useState<NewsItem | null>(null);
|
const [selected, setSelected] = useState<NewsItem | null>(null);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
if (!data.items || data.items.length === 0) return null;
|
if (!data.items || data.items.length === 0) return null;
|
||||||
|
|
||||||
const [featured, ...rest] = data.items;
|
// Sort by date, newest first
|
||||||
|
const sorted = [...data.items].sort((a, b) => (b.date || "").localeCompare(a.date || ""));
|
||||||
|
const [featured, ...rest] = sorted;
|
||||||
|
const visibleRest = showAll ? rest : rest.slice(0, INITIAL_VISIBLE - 1);
|
||||||
|
const hasMore = rest.length > INITIAL_VISIBLE - 1 && !showAll;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="news" className="section-glow relative section-padding">
|
<section id="news" className="section-glow relative section-padding">
|
||||||
@@ -129,12 +136,12 @@ export function News({ data }: NewsProps) {
|
|||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{rest.length > 0 && (
|
{visibleRest.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
||||||
{rest.map((item) => (
|
{visibleRest.map((item) => (
|
||||||
<CompactArticle
|
<CompactArticle
|
||||||
key={item.title}
|
key={item.title + item.date}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={() => setSelected(item)}
|
onClick={() => setSelected(item)}
|
||||||
/>
|
/>
|
||||||
@@ -142,6 +149,19 @@ export function News({ data }: NewsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(true)}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Показать ещё ({rest.length - INITIAL_VISIBLE + 1})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user