feat: photo-search, system settings, ROADMAP

- PhotoSearchModal: Yandex photo-search с профилями доменов
- SystemSettings: управление app_settings (admin-only, /system)
- ROADMAP.md: актуальный план фич P1-P10
- Header, ChannelView, session: поддержка is_admin
This commit is contained in:
Nik (Claude)
2026-06-07 14:04:14 +03:00
parent 76eb519018
commit 2e550d2993
14 changed files with 931 additions and 38 deletions
+158
View File
@@ -0,0 +1,158 @@
# ZeroPost — Roadmap (план фич)
Живой документ. Обновлять по мере выполнения.
Последнее обновление: 2026-06-01
---
## Контекст / архитектура
- **zeropost.ru** (web) — блог + админка. PM2 `zeropost-web` (pm_id=5, порт 3042), `/var/www/zeropost-web`.
- **app.zeropost.ru** (tool) — SaaS для управления каналами TG/VK/MAX. PM2 `zeropost-tool` (pm_id=4, порт 3041), `/var/www/zeropost-tool`.
- **engine** — общий движок API + LLM-pipeline. PM2 `zeropost-engine` (pm_id=3, порт 3030), `/var/www/zeropost-engine`.
- БД: PostgreSQL, db=`zeropost`.
- Cron: `*/10 * * * * zeropost-autogen.sh`, `* * * * * zeropost-publish-scheduled.sh` (обе очереди).
- Internal secret: `zeropost_internal_2026`. TG-прокси: CF Worker в `app_settings.TELEGRAM_API_BASE`.
---
## ✅ DONE
### Photo-search (Yandex)
- `user_posts.image_credit` (JSONB), `users.is_admin` (BOOLEAN).
- Engine: proxy `/api/photo-search/*`. Tool: `/system` (admin-only), `PhotoSearchModal`.
- Web: категория `photo_search` скрыта из blog-админки.
### Auto-publish статей блога → каналы
- `channels.auto_publish_{enabled,categories,delay_min,template,with_cover,button_text,image_source}`.
- Engine: `articleAutoPublish.js`, `scheduledPostsRunner.js`, `/api/scheduled-posts/*`.
- Хук в `PATCH/POST articles` при `draft→published`. Typeahead `/api/articles/admin/search`.
- Cron обновлён — дёргает обе очереди (`user_posts` + `scheduled_posts`).
- Web: `ArticlePicker`, `AutoPublishTab`, 4 вкладки в `ChannelEditor`.
### Журнальная главная страница zeropost.ru
- Engine: `/api/articles/home` (hero / byCategory / popular / recent).
- Web: `CategoryRow`, `PopularBlock`, `RecentBlock` (группировка Сегодня/Вчера/Эта неделя).
- `ArticleCard` с 3 размерами (hero/regular/compact) + цветной category badge без дублей.
- Header упрощён (2 пункта desktop + расширенное мобильное меню).
### Персонаж Зеро
- 15 поз: `avatar, coding, tools, lock, gears, eureka, confused, facepalm, victory, tired, reading, magnifier, chart, meditate, present``/var/www/zeropost-uploads/zero-{name}.webp`.
- `src/services/zeroCharacter.js` — выбор позы по тексту/категории статьи (эмоциональные триггеры + категорийные).
- `channels.auto_publish_image_source = 'alternating'` — чётные посты = AI-обложка, нечётные = Зеро.
- `scheduledPostsRunner` — multipart upload (не URL) чтобы CF Worker не падал.
### Промпт Зеро
- `src/services/promptBuilder.js` — секция `author_persona` в `buildArticleSystemPrompt`.
- `src/services/articles.js``blogChannel.author_persona` (голос, правила, запрещённые фразы).
- Статьи теперь пишутся от первого лица с личными историями.
### TG-канал @zeropostru — запуск
- Welcome-пост от Зеро с аватаром + кнопкой (msg_id=13, закреплён).
- 4 статьи опубликованы (по одной на каждую категорию), режим alternating.
### zeropost.ru — страница Зеро + TG-продвижение
- `/about/zero` — страница с описанием персонажа + галерея 8 поз.
- Footer — TG-банер с аватаром Зеро на каждой странице.
- Конец каждой статьи — блок «Понравилась заметка? → В канал».
- `/about` — ссылка «Познакомьтесь с Зеро».
---
## 🚀 ПЛАН: что делать дальше
### СЕЙЧАС (не отложить)
**A. Revoke бота** — токен @zeropostru_bot засветился в этом чате. Зайди в @BotFather → /mybots → выбери бота → API Token → Revoke. Потом обнови в `/admin/channels/1` → Настройки → Bot Token.
**B. Статья на Habr** — главный бесплатный способ получить первые 200-500 живых читателей. Тема: «Я сделал блог, который ведёт ИИ с персонажем-маскотом. Как работает pipeline». Напишу черновик — дай команду.
---
### app.zeropost.ru — приоритетный порядок
#### P1. Календарь публикаций (1–2 дня)
Самый частый запрос у SMM-инструментов. Без него непонятно «что когда выходит».
- Страница `/calendar` — визуальная сетка (неделя + месяц).
- Данные: `user_posts.scheduled_at` + `publish_slots` канала.
- Карточки по цветам: draft=серый, scheduled=синий, published=зелёный, failed=красный.
- Drag & drop между датами → PATCH `scheduled_at`.
- Фильтр по каналу.
#### P2. Превью под платформу (0.5–1 день)
Сейчас пишешь пост и не знаешь как он будет выглядеть.
- Компонент `PostPreview` в `ChannelView` справа от textarea.
- Рендерит Markdown как TG: **жирный**, _курсив_, обрезка caption 1024, превью ссылки.
- Переключатель TG / VK / MAX — разные ограничения форматирования.
#### P3. Шаблоны постов (0.5 дня)
Ускоряет создание поста в 3 раза.
- 7 кнопок-пресетов: Новость, Анонс, Кейс, Лонгрид, Подборка, Опрос-разбор, Личное мнение.
- Каждый — готовая структура поста + hint для AI.
- В `ChannelView` рядом с кнопкой «Идеи тем».
#### P4. Аналитика постов (2–3 дня)
Без метрик невозможно понять что заходит.
- Таблица `post_metrics(user_post_id, captured_at, views, forwards, reactions JSONB)`.
- Воркер раз в 15 мин: пуллит views через TG embed для постов < 7 дней.
- В `ChannelView` у каждого поста — строчка «👁 N ↗ N ❤️ N».
- График: «лучший день/час для публикации» по медиане views.
#### P5. URL → черновик (1–2 дня)
Killer feature которой нет у конкурентов в таком качестве.
- Вставил ссылку → AI читает статью/YouTube/TG-пост → пишет пост в стиле канала.
- `POST /api/generate/from-url`. Для статей: cheerio + og-meta. Для YouTube: yt-dlp транскрипт.
#### P6. Комментарии + AI-ответы (4–7 дней)
Большая фича, отдельный спринт.
- TG webhook → unified inbox.
- Классификатор haiku: вопрос/спам/похвала/троллинг.
- Предложенный AI-ответ с кнопкой «отправить».
#### P7–P10. Опросы, хештеги, best-time, URL-shortener
По 0.5–1 дню каждое, делаем после P1–P5.
---
### zeropost.ru — мелкий должок
| Задача | Срочность |
|---|---|
| Revoke бота @zeropostru_bot | 🔴 СЕЙЧАС |
| Статья на Habr про ZeroPost | 🟠 На этой неделе |
| Кнопка «Бэкфилл статей» в AutoPublishTab | 🟡 Низкая |
| История публикаций канала | 🟡 Низкая |
| Балансы внешних сервисов в /system | 🟡 Низкая |
---
### zeropost.ru — рост аудитории (без бюджета)
| Действие | Ожидаемый результат | Когда |
|---|---|---|
| Habr: «Как я сделал AI-блог с маскотом» | 200–500 переходов | На этой неделе |
| vc.ru: то же самое | 100–300 переходов | На этой неделе |
| 5 комментариев в AI-чатах TG | 20–50 подписчиков | Постоянно |
| Взаимный пиар с 3 каналами (tgstat.ru) | 50150 подписчиков | 1–2 недели |
| Reddit r/artificial | 50200 переходов | На этой неделе |
| SEO (органика) | Долгосрочно | Само, 2–3 мес |
---
## Технический долг
- ENGINE_URL default в `lib/engine.js` tool'а = 3040, должен быть 3030.
- `per_day` в `autogen_settings` не используется в логике (только `run_hour:run_minute`).
- TOPIC_BANK заканчивается — нужен AI-генератор новых тем.
- VK: публикация без фото (нужен 2-step `photos.getWallUploadServer`).
- MAX: заглушка `throw new Error('не реализована')`.
---
## Не делаем
- Холст / визуальный редактор.
- Stories / Reels.
- Команда / роли (до 5+ клиентов).
- White Label.
- Парсинг конкурентов через MTProto.
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function PUT(req, { params }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { key } = await params;
const body = await req.json();
const row = await engine.updateSetting(key, body?.value);
return NextResponse.json(row);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
// GET /api/admin/settings?category=photo_search
export async function GET(req) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { searchParams } = new URL(req.url);
const category = searchParams.get('category') || undefined;
const rows = await engine.listSettings(category);
return NextResponse.json(rows);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+11 -3
View File
@@ -16,19 +16,23 @@ export async function POST(req) {
} }
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
const { rows } = await q( const { rows } = await q(
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`, `INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name,is_admin`,
[email, hash] [email, hash]
); );
const user = rows[0]; const user = rows[0];
const s = await getSession(); const s = await getSession();
s.userId = user.id; s.userId = user.id;
s.email = user.email; s.email = user.email;
s.isAdmin = !!user.is_admin;
await s.save(); await s.save();
return NextResponse.json({ ok: true, user }); return NextResponse.json({ ok: true, user });
} }
// login // login
const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]); const { rows } = await q(
`SELECT id,email,password,name,is_admin FROM users WHERE email=$1`,
[email]
);
if (!rows.length) { if (!rows.length) {
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 }); return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
} }
@@ -41,6 +45,10 @@ export async function POST(req) {
s.userId = user.id; s.userId = user.id;
s.email = user.email; s.email = user.email;
s.name = user.name; s.name = user.name;
s.isAdmin = !!user.is_admin;
await s.save(); await s.save();
return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } }); return NextResponse.json({
ok: true,
user: { id: user.id, email: user.email, name: user.name, isAdmin: !!user.is_admin },
});
} }
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function POST(req) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await req.json();
const data = await engine.photoSearchByQuery(body);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: err.status || 500 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET() {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const data = await engine.photoSearchProfiles();
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET() {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const data = await engine.photoSearchQuota();
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import Header from '@/components/Header';
import SystemSettings from '@/components/SystemSettings';
export const dynamic = 'force-dynamic';
export default async function SystemPage() {
const user = await requireUser();
if (!user) redirect('/login');
if (!user.isAdmin) redirect('/');
return (
<>
<Header user={user} />
<main className="max-w-4xl mx-auto p-4 sm:p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold">Системные настройки</h1>
<p className="text-sm text-gray-500 mt-1">
Конфигурация внешних сервисов (поиск фото, билинги и т.п.). Видно только админам.
</p>
</div>
<SystemSettings />
</main>
</>
);
}
+136 -26
View File
@@ -4,8 +4,9 @@ import Link from 'next/link';
import { import {
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings, ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart, Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2 MessageSquare, Pencil, X, Send, Clock, Search, Camera, ExternalLink
} from 'lucide-react'; } from 'lucide-react';
import PhotoSearchModal from './PhotoSearchModal';
const GOAL_LABELS = { const GOAL_LABELS = {
educational: 'Обучение', news: 'Новости', educational: 'Обучение', news: 'Новости',
@@ -22,6 +23,15 @@ const TRANSFORMS = [
{ action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' }, { action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' },
]; ];
// Хвостовая подпись «📷 Фото: domain» — добавляется к посту, можно убрать
function buildCaption(domain) {
return domain ? `\n\n📷 Фото: ${domain}` : '';
}
// Удаляет существующую подпись из текста поста (по любому домену)
function stripCaption(text) {
return (text || '').replace(/\n{1,2}📷\s*Фото:\s*[^\n]+\s*$/u, '').trimEnd();
}
export default function ChannelView({ channel }) { export default function ChannelView({ channel }) {
const [topic, setTopic] = useState(''); const [topic, setTopic] = useState('');
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
@@ -36,8 +46,12 @@ export default function ChannelView({ channel }) {
// Картинка // Картинка
const [image, setImage] = useState(null); const [image, setImage] = useState(null);
const [imageCredit, setImageCredit] = useState(null); // { domain, sourceUrl, title } | null
const [genImage, setGenImage] = useState(false); const [genImage, setGenImage] = useState(false);
// Photo search modal
const [showPhotoSearch, setShowPhotoSearch] = useState(false);
// Трансформации // Трансформации
const [transforming, setTransforming] = useState(false); const [transforming, setTransforming] = useState(false);
@@ -74,7 +88,6 @@ export default function ChannelView({ channel }) {
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false);
// Подгрузка истории при монтировании
useEffect(() => { loadHistory(); }, []); useEffect(() => { loadHistory(); }, []);
async function loadHistory() { async function loadHistory() {
@@ -86,6 +99,23 @@ export default function ChannelView({ channel }) {
} catch {} finally { setLoadingHistory(false); } } catch {} finally { setLoadingHistory(false); }
} }
function clearImage() {
setImage(null);
setImageCredit(null);
// Если в посте была подпись «📷 Фото: …» — убираем её при удалении фото
if (post) setPost(p => stripCaption(p));
}
function applyPhotoPick({ imageUrl, credit }) {
setImage(imageUrl);
setImageCredit(credit || null);
// Подменяем (или добавляем) caption
if (post && credit?.domain) {
setPost(p => stripCaption(p) + buildCaption(credit.domain));
}
setShowPhotoSearch(false);
}
async function savePost(status = 'draft', scheduledAt = null) { async function savePost(status = 'draft', scheduledAt = null) {
if (!post) return; if (!post) return;
setPublishing(true); setPublishing(true);
@@ -93,12 +123,12 @@ export default function ChannelView({ channel }) {
try { try {
let id = savedPostId; let id = savedPostId;
if (!id) { if (!id) {
// Создаём
const res = await fetch('/api/user-posts', { const res = await fetch('/api/user-posts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
channel_id: channel.id, content: post, image_url: image, channel_id: channel.id, content: post,
image_url: image, image_credit: imageCredit,
topic: topic.trim(), status, scheduled_at: scheduledAt, topic: topic.trim(), status, scheduled_at: scheduledAt,
}), }),
}); });
@@ -107,11 +137,15 @@ export default function ChannelView({ channel }) {
id = data.id; id = data.id;
setSavedPostId(id); setSavedPostId(id);
} else { } else {
// Обновляем
const res = await fetch(`/api/user-posts/${id}`, { const res = await fetch(`/api/user-posts/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: post, image_url: image, status, scheduled_at: scheduledAt }), body: JSON.stringify({
content: post,
image_url: image,
image_credit: imageCredit,
status, scheduled_at: scheduledAt,
}),
}); });
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка'); if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
} }
@@ -137,6 +171,7 @@ export default function ChannelView({ channel }) {
setPost(null); setPost(null);
setSavedPostId(null); setSavedPostId(null);
setImage(null); setImage(null);
setImageCredit(null);
setTopic(''); setTopic('');
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
finally { setPublishing(false); } finally { setPublishing(false); }
@@ -151,6 +186,7 @@ export default function ChannelView({ channel }) {
setPost(null); setPost(null);
setSavedPostId(null); setSavedPostId(null);
setImage(null); setImage(null);
setImageCredit(null);
setTopic(''); setTopic('');
} }
@@ -183,14 +219,14 @@ export default function ChannelView({ channel }) {
if (!final) throw new Error('Таймаут — попробуй ещё раз'); if (!final) throw new Error('Таймаут — попробуй ещё раз');
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала'); if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
// Сохраняем предыдущий вариант в variants
if (asVariant && post) { if (asVariant && post) {
setVariants(v => [...v, { content: post, tokens, image }]); setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
} }
setPost(final.result); setPost(final.result);
setTokens({ in: final.tokens_in, out: final.tokens_out }); setTokens({ in: final.tokens_in, out: final.tokens_out });
setImage(null); // сбрасываем картинку при новом посте setImage(null);
setImageCredit(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -210,10 +246,10 @@ export default function ChannelView({ channel }) {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка'); if (!res.ok) throw new Error(data.error || 'Ошибка');
// Сохраняем текущий в варианты setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
setVariants(v => [...v, { content: post, tokens, image }]);
setPost(data.content); setPost(data.content);
setImage(null); setImage(null);
setImageCredit(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -234,6 +270,7 @@ export default function ChannelView({ channel }) {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки'); if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
setImage(data.url); setImage(data.url);
setImageCredit(null); // сгенерированная — без credit'а
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -245,12 +282,13 @@ export default function ChannelView({ channel }) {
const v = variants[idx]; const v = variants[idx];
setVariants(arr => { setVariants(arr => {
const next = arr.filter((_, i) => i !== idx); const next = arr.filter((_, i) => i !== idx);
next.push({ content: post, tokens, image }); next.push({ content: post, tokens, image, imageCredit });
return next; return next;
}); });
setPost(v.content); setPost(v.content);
setTokens(v.tokens); setTokens(v.tokens);
setImage(v.image); setImage(v.image);
setImageCredit(v.imageCredit || null);
} }
async function copy() { async function copy() {
@@ -299,7 +337,6 @@ export default function ChannelView({ channel }) {
</button> </button>
</div> </div>
{/* Список идей */}
{showIdeas && ideas.length > 0 && ( {showIdeas && ideas.length > 0 && (
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20"> <div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -384,7 +421,6 @@ export default function ChannelView({ channel }) {
</div> </div>
</div> </div>
{/* Сам пост — редактируемый или нет */}
{editing ? ( {editing ? (
<textarea <textarea
value={post} value={post}
@@ -400,34 +436,90 @@ export default function ChannelView({ channel }) {
{/* Картинка к посту */} {/* Картинка к посту */}
{image && ( {image && (
<div className="mt-4 relative"> <div className="mt-4">
<img src={image} alt="" className="w-full rounded-lg" /> <div className="relative">
<img src={image} alt="" className="w-full rounded-lg" referrerPolicy="no-referrer" />
<button <button
onClick={() => setImage(null)} onClick={clearImage}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white" className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
title="Убрать картинку"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
{imageCredit?.domain && (
<div className="mt-2 flex items-center justify-between flex-wrap gap-2 text-xs">
<div className="text-gray-500">
📷 Фото:{' '}
{imageCredit.sourceUrl ? (
<a
href={imageCredit.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline inline-flex items-center gap-1"
>
{imageCredit.domain}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-accent">{imageCredit.domain}</span>
)}
</div>
<button
onClick={() => setPost(p => stripCaption(p))}
className="text-gray-500 hover:text-gray-300"
title="Убрать подпись «📷 Фото: …» из текста поста (саму картинку оставить)"
>
убрать подпись из поста
</button>
</div>
)}
</div>
)} )}
{/* Кнопка генерации картинки */} {/* Кнопки получения картинки */}
{!image && ( {!image && (
<div className="mt-4"> <div className="mt-4 grid sm:grid-cols-2 gap-2">
<button
onClick={() => setShowPhotoSearch(true)}
className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors"
>
<Search className="w-4 h-4" /> Найти фото
</button>
<button <button
onClick={generateImage} onClick={generateImage}
disabled={genImage} disabled={genImage}
className="w-full inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50" className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
> >
{genImage ? ( {genImage ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую картинку... (~30 сек)</> <><Loader2 className="w-4 h-4 animate-spin" /> Генерирую (~30 сек)</>
) : ( ) : (
<><ImageIcon className="w-4 h-4" /> Сгенерировать картинку</> <><Camera className="w-4 h-4" /> Сгенерировать (AI)</>
)} )}
</button> </button>
</div> </div>
)} )}
{/* Если уже есть картинка — даём ещё раз поменять */}
{image && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<button
onClick={() => setShowPhotoSearch(true)}
className="btn-ghost text-xs py-1"
>
<Search className="w-3.5 h-3.5" /> Другое фото
</button>
<button
onClick={generateImage}
disabled={genImage}
className="btn-ghost text-xs py-1"
>
{genImage ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
Заменить AI-картинкой
</button>
</div>
)}
{/* Публикация */} {/* Публикация */}
<div className="mt-5 pt-4 border-t border-border"> <div className="mt-5 pt-4 border-t border-border">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Публикация</div> <div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Публикация</div>
@@ -457,7 +549,6 @@ export default function ChannelView({ channel }) {
</button> </button>
</div> </div>
{/* Планировщик */}
{showScheduler && ( {showScheduler && (
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border"> <div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
<label className="label text-xs">Время публикации (МСК)</label> <label className="label text-xs">Время публикации (МСК)</label>
@@ -498,6 +589,15 @@ export default function ChannelView({ channel }) {
</div> </div>
)} )}
{/* Photo search modal */}
<PhotoSearchModal
open={showPhotoSearch}
onClose={() => setShowPhotoSearch(false)}
topic={topic}
post={post}
onPick={applyPhotoPick}
/>
{/* История вариантов */} {/* История вариантов */}
{variants.length > 0 && ( {variants.length > 0 && (
<div className="card p-5"> <div className="card p-5">
@@ -541,11 +641,11 @@ export default function ChannelView({ channel }) {
return ( return (
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border"> <div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
{p.image_url && ( {p.image_url && (
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" /> <img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" referrerPolicy="no-referrer" />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div> <div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs flex-wrap">
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}> <span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
{statusLabels[p.status] || p.status} {statusLabels[p.status] || p.status}
</span> </span>
@@ -558,13 +658,23 @@ export default function ChannelView({ channel }) {
{!p.scheduled_at && !p.published_at && ( {!p.scheduled_at && !p.published_at && (
<span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span> <span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)} )}
{p.image_credit?.domain && (
<span className="text-gray-500">📷 {p.image_credit.domain}</span>
)}
{p.error && ( {p.error && (
<span className="text-red-400 truncate">{p.error}</span> <span className="text-red-400 truncate">{p.error}</span>
)} )}
</div> </div>
</div> </div>
<button <button
onClick={() => { setPost(p.content); setImage(p.image_url); setSavedPostId(p.id); setTopic(p.topic || ''); window.scrollTo({top: 0, behavior: 'smooth'}); }} onClick={() => {
setPost(p.content);
setImage(p.image_url);
setImageCredit(p.image_credit || null);
setSavedPostId(p.id);
setTopic(p.topic || '');
window.scrollTo({top: 0, behavior: 'smooth'});
}}
className="text-xs text-accent hover:underline shrink-0" className="text-xs text-accent hover:underline shrink-0"
> >
открыть открыть
+11 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Sparkles, LogOut } from 'lucide-react'; import { Sparkles, LogOut, Settings2 } from 'lucide-react';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
export default function Header({ user }) { export default function Header({ user }) {
@@ -18,6 +18,16 @@ export default function Header({ user }) {
<span className="font-bold">ZeroPost</span> <span className="font-bold">ZeroPost</span>
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user?.isAdmin && (
<Link
href="/system"
className="btn-ghost p-2 text-sm hidden sm:inline-flex"
title="Системные настройки"
>
<Settings2 className="w-4 h-4" />
<span className="hidden md:inline">Система</span>
</Link>
)}
<span className="text-sm text-gray-500 hidden sm:inline mr-2">{user?.email}</span> <span className="text-sm text-gray-500 hidden sm:inline mr-2">{user?.email}</span>
<ThemeToggle /> <ThemeToggle />
<button onClick={logout} className="btn-ghost p-2" title="Выйти"> <button onClick={logout} className="btn-ghost p-2" title="Выйти">
+287
View File
@@ -0,0 +1,287 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, Search, X, AlertCircle, ExternalLink, Check } from 'lucide-react';
// Простая эвристика: если в тексте поста есть капитализованные ФИО (двусловные) —
// предлагаем их как стартовый query. Иначе берём первые слова темы.
function suggestQuery({ topic, post }) {
const text = `${topic || ''}\n${post || ''}`.trim();
if (!text) return '';
// Имена вида «Имя Фамилия» (две заглавные кириллицей или латиницей подряд)
const reName = /\b([А-ЯЁA-Z][а-яёa-z]{2,})\s+([А-ЯЁA-Z][а-яёa-z]{2,})/g;
const matches = [];
let m;
while ((m = reName.exec(text)) !== null) {
matches.push(`${m[1]} ${m[2]}`);
if (matches.length >= 3) break;
}
if (matches.length) return matches[0];
// Иначе — первые ~6 слов темы
return (topic || '').split(/\s+/).slice(0, 6).join(' ');
}
export default function PhotoSearchModal({ open, onClose, topic, post, onPick }) {
const [profiles, setProfiles] = useState([]);
const [profile, setProfile] = useState('general');
const [quota, setQuota] = useState(null);
const [query, setQuery] = useState('');
const [items, setItems] = useState([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [meta, setMeta] = useState(null);
const [pickedIdx, setPickedIdx] = useState(null);
// Загружаем профили + квоту при первом открытии
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const [profRes, quotaRes] = await Promise.all([
fetch('/api/photo-search/profiles'),
fetch('/api/photo-search/quota'),
]);
const profData = profRes.ok ? await profRes.json() : [];
const quotaData = quotaRes.ok ? await quotaRes.json() : null;
if (cancelled) return;
setProfiles(profData);
setQuota(quotaData);
// Дефолт-профиль: general (если есть), иначе первый
if (profData.length && !profData.find(p => p.slug === profile)) {
setProfile(profData[0].slug);
}
} catch (e) {
if (!cancelled) setError(e.message);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Подсказываем стартовый запрос
useEffect(() => {
if (!open) return;
if (!query) setQuery(suggestQuery({ topic, post }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function search() {
if (!query.trim() || searching) return;
setSearching(true);
setError('');
setItems([]);
setPickedIdx(null);
try {
const res = await fetch('/api/photo-search/by-query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.trim(), profile, num: 6 }),
});
const data = await res.json();
if (!res.ok) {
if (data.code === 'DAILY_LIMIT_EXCEEDED') {
throw new Error('Дневной лимит поиска фото исчерпан. Попробуй завтра или подними лимит в /system.');
}
throw new Error(data.error || `HTTP ${res.status}`);
}
setItems(data.items || []);
setMeta({
total: data.total,
raw: data.raw_count,
filtered: data.filtered_count,
elapsedMs: data.elapsed_ms,
domains: data.domains || [],
});
if (data.quota) setQuota(data.quota);
if (!data.items || data.items.length === 0) {
setError('Ничего не нашлось в whitelisted доменах. Попробуй другой профиль или уточни запрос.');
}
} catch (e) {
setError(e.message);
} finally {
setSearching(false);
}
}
function pick(idx) {
const item = items[idx];
if (!item) return;
setPickedIdx(idx);
onPick?.({
imageUrl: item.imageUrl,
thumbUrl: item.thumbUrl,
credit: {
domain: item.sourceDomain || null,
sourceUrl: item.sourceUrl || null,
title: item.title || null,
},
});
}
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start sm:items-center justify-center p-3 sm:p-6 overflow-y-auto"
onClick={onClose}
>
<div
className="card w-full max-w-3xl my-4"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-border gap-3">
<div>
<h3 className="font-semibold flex items-center gap-2">
<Search className="w-4 h-4 text-accent" />
Найти фото
</h3>
<p className="text-xs text-gray-500 mt-1">
Поиск по доменам из whitelist'а профиля. Используется Yandex Search API.
</p>
</div>
<button onClick={onClose} className="btn-ghost p-2" title="Закрыть">
<X className="w-4 h-4" />
</button>
</div>
{/* Controls */}
<div className="p-5 space-y-3 border-b border-border">
<div className="grid sm:grid-cols-[1fr_auto] gap-2">
<div>
<label className="label text-xs">Запрос</label>
<input
className="input text-sm"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') search(); }}
placeholder="Имя, событие, объект…"
autoFocus
/>
</div>
<div>
<label className="label text-xs">Профиль</label>
<select
className="input text-sm"
value={profile}
onChange={e => setProfile(e.target.value)}
>
{profiles.length === 0 && <option value="general">general</option>}
{profiles.map(p => (
<option key={p.slug} value={p.slug}>
{p.name || p.slug} {p.domains?.length ? `(${p.domains.length})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-xs text-gray-500">
{quota && (
<>Квота сегодня: <b>{quota.used}</b> / {quota.limit}{' '}
{quota.remaining === 0 && <span className="text-amber-500">(исчерпана)</span>}
</>
)}
</div>
<button
onClick={search}
disabled={searching || !query.trim() || (quota?.remaining === 0)}
className="btn-primary text-sm"
>
{searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
{searching ? 'Ищу' : 'Найти'}
</button>
</div>
{profiles.find(p => p.slug === profile)?.domains?.length > 0 && (
<div className="text-[11px] text-gray-500">
Whitelist:{' '}
{profiles.find(p => p.slug === profile).domains.slice(0, 8).join(', ')}
{profiles.find(p => p.slug === profile).domains.length > 8 && ''}
</div>
)}
</div>
{/* Body */}
<div className="p-5">
{error && (
<div className="flex items-start gap-2 text-sm text-amber-500 bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 mb-3">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<div>{error}</div>
</div>
)}
{meta && !error && (
<div className="text-[11px] text-gray-500 mb-3">
Найдено всего: {meta.total} · после фильтра: {meta.filtered} · показано: {items.length} · {meta.elapsedMs} мс
</div>
)}
{items.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{items.map((it, i) => (
<button
key={i}
onClick={() => pick(i)}
className={`group relative rounded-lg overflow-hidden border transition-all text-left ${
pickedIdx === i
? 'border-accent ring-2 ring-accent/40'
: 'border-border hover:border-accent/60'
}`}
title={it.title || it.sourceUrl}
>
<div className="aspect-square bg-surface2 overflow-hidden">
<img
src={it.thumbUrl || it.imageUrl}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => { e.currentTarget.style.opacity = '0.3'; }}
/>
</div>
<div className="p-2">
<div className="text-[10px] text-accent font-mono truncate">{it.sourceDomain}</div>
{it.title && (
<div className="text-[11px] text-gray-500 line-clamp-2 mt-0.5">{it.title}</div>
)}
<div className="text-[10px] text-gray-500 mt-1">{it.width}×{it.height}</div>
</div>
{pickedIdx === i && (
<div className="absolute top-1.5 right-1.5 bg-accent text-white rounded-full p-1">
<Check className="w-3 h-3" />
</div>
)}
{it.sourceUrl && (
<a
href={it.sourceUrl}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="absolute top-1.5 left-1.5 bg-black/60 hover:bg-black/80 text-white rounded-full p-1"
title="Открыть источник"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</button>
))}
</div>
)}
{items.length === 0 && !error && !searching && (
<div className="text-sm text-gray-500 text-center py-10">
Введи запрос и нажми «Найти»
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-border flex items-center justify-end gap-2">
<button onClick={onClose} className="btn-ghost text-sm">Закрыть</button>
</div>
</div>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle } from 'lucide-react';
// Категории, которые управляются здесь (в админке tool, а не в админке блога).
// Категория `engine` (TELEGRAM_API_BASE и т.п.) намеренно живёт в zeropost.ru/admin.
const CATEGORIES = [
{ slug: 'photo_search', title: 'Поиск фото',
hint: 'Yandex Search API: provider, ключ, folder, лимиты.' },
// Сюда позже: { slug: 'billing', ... }, { slug: 'serpapi', ... }
];
export default function SystemSettings() {
const [byCategory, setByCategory] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
async function load() {
setLoading(true);
setError('');
try {
const result = {};
for (const cat of CATEGORIES) {
const res = await fetch(`/api/admin/settings?category=${cat.slug}`);
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
result[cat.slug] = await res.json();
}
setByCategory(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
if (loading) {
return (
<div className="card p-12 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" />
</div>
);
}
if (error) {
return (
<div className="card p-5 border-red-500/40">
<div className="flex items-center gap-2 text-red-400">
<AlertCircle className="w-4 h-4" />
{error}
</div>
<button onClick={load} className="btn-ghost mt-3 text-sm">
<RefreshCw className="w-4 h-4" /> Повторить
</button>
</div>
);
}
return (
<div className="space-y-6">
{CATEGORIES.map(cat => (
<CategoryBlock
key={cat.slug}
category={cat}
rows={byCategory[cat.slug] || []}
onSaved={() => load()}
/>
))}
</div>
);
}
function CategoryBlock({ category, rows, onSaved }) {
return (
<section className="card p-5">
<div className="mb-4">
<h2 className="font-semibold">{category.title}</h2>
{category.hint && <p className="text-xs text-gray-500 mt-1">{category.hint}</p>}
</div>
{rows.length === 0 ? (
<p className="text-sm text-gray-500">Нет настроек в этой категории.</p>
) : (
<div className="space-y-3">
{rows.map(r => (
<SettingRow key={r.key} row={r} onSaved={onSaved} />
))}
</div>
)}
</section>
);
}
function SettingRow({ row, onSaved }) {
const [value, setValue] = useState(row.value ?? '');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [reveal, setReveal] = useState(false);
const [err, setErr] = useState('');
// если row.value меняется снаружи — синхронизируем
useEffect(() => { setValue(row.value ?? ''); }, [row.value]);
const isSecret = row.is_secret;
const dirty = value !== (row.value ?? '');
async function save() {
setSaving(true);
setErr('');
try {
const res = await fetch(`/api/admin/settings/${encodeURIComponent(row.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: value === '' ? null : value }),
});
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
setSaved(true);
setTimeout(() => setSaved(false), 1500);
onSaved?.();
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
}
// Маскировка отображения секретов: показываем хвост ***last4 пока не reveal=true
const masked = isSecret && value && !reveal
? '•'.repeat(Math.max(value.length - 4, 4)) + value.slice(-4)
: value;
return (
<div className="rounded-lg border border-border bg-surface2/50 p-3">
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<code className="text-sm font-mono">{row.key}</code>
{isSecret && (
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500">
secret
</span>
)}
</div>
{row.description && (
<p className="text-xs text-gray-500 mt-1">{row.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<input
type={isSecret && !reveal ? 'password' : 'text'}
className="input text-sm font-mono"
value={reveal || !isSecret ? value : masked}
onChange={e => {
// При маскированном просмотре редактирование запрещаем — пусть сначала откроют
if (isSecret && !reveal) return;
setValue(e.target.value);
}}
placeholder={isSecret ? '(скрыто)' : '(пусто)'}
spellCheck={false}
/>
{isSecret && (
<button
type="button"
onClick={() => setReveal(v => !v)}
className="btn-ghost p-2"
title={reveal ? 'Скрыть' : 'Показать'}
>
{reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
onClick={save}
disabled={saving || !dirty}
className="btn-primary text-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" />
: saved ? <Check className="w-4 h-4" />
: <Save className="w-4 h-4" />}
{saved ? 'Сохранено' : 'Сохранить'}
</button>
</div>
{err && (
<div className="text-xs text-red-400 mt-2">{err}</div>
)}
<div className="text-[11px] text-gray-500 mt-2">
Категория: <code>{row.category}</code> · обновлено{' '}
{row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'}
</div>
</div>
);
}
+17 -1
View File
@@ -21,7 +21,10 @@ async function call(path, options = {}) {
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })); const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `Engine ${res.status}`); const e = new Error(err.error || `Engine ${res.status}`);
e.status = res.status;
e.code = err.code;
throw e;
} }
return res.json(); return res.json();
} }
@@ -52,4 +55,17 @@ export const engine = {
updatePost: (userId, id, data) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: data }), updatePost: (userId, id, data) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: data }),
deletePost: (userId, id) => call(`/api/user-posts/${id}`, { userId, method: 'DELETE' }), deletePost: (userId, id) => call(`/api/user-posts/${id}`, { userId, method: 'DELETE' }),
publishPost: (userId, id) => call(`/api/user-posts/${id}/publish`, { userId, method: 'POST' }), publishPost: (userId, id) => call(`/api/user-posts/${id}/publish`, { userId, method: 'POST' }),
// Photo search
photoSearchProfiles: () => call('/api/photo-search/profiles'),
photoSearchQuota: () => call('/api/photo-search/quota'),
photoSearchByQuery: (data) => call('/api/photo-search/by-query', { method: 'POST', body: data }),
// Settings (admin)
listSettings: (category) => {
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
return call(`/api/settings/admin${qs}`);
},
updateSetting: (key, value) => call(`/api/settings/admin/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } }),
invalidateSettingsCache: () => call('/api/settings/admin/invalidate', { method: 'POST' }),
}; };
+7 -1
View File
@@ -20,5 +20,11 @@ export async function getSession() {
export async function requireUser() { export async function requireUser() {
const s = await getSession(); const s = await getSession();
if (!s.userId) return null; if (!s.userId) return null;
return { id: s.userId, email: s.email, name: s.name }; return { id: s.userId, email: s.email, name: s.name, isAdmin: !!s.isAdmin };
}
export async function requireAdmin() {
const u = await requireUser();
if (!u || !u.isAdmin) return null;
return u;
} }