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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user