feat: светлая тема как основная + переключатель тем
- CSS-переменные --bg, --surface, --ink, --mute, --accent для обеих тем - darkMode: 'class' в Tailwind config - ThemeToggle компонент с Sun/Moon, сохраняет выбор в localStorage - Inline-скрипт в layout.js защищает от FOUC (FlashOfUnstyledContent) - Авто-определение по prefers-color-scheme как fallback - not-found.js: красивая 404 страница вместо дефолтной Next - Обновлены все компоненты и страницы — Header, Footer, ArticleCard, page.js, blog, tag, about
This commit is contained in:
+7
-4
@@ -1,6 +1,6 @@
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { Sparkles, Cpu, BookOpen, Zap } from 'lucide-react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
export const metadata = { title: 'О проекте' };
|
||||
|
||||
@@ -9,13 +9,16 @@ export default function AboutPage() {
|
||||
<>
|
||||
<Header />
|
||||
<main className="container-narrow pt-12 pb-16">
|
||||
<div className="inline-flex items-center gap-2 text-xs text-accent bg-accent/10 border border-accent/20 px-3 py-1.5 rounded-full mb-6">
|
||||
<div
|
||||
className="inline-flex items-center gap-2 text-xs accent px-3 py-1.5 rounded-full mb-6"
|
||||
style={{ background: 'rgb(var(--accent) / 0.1)', border: '1px solid rgb(var(--accent) / 0.2)' }}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" /> О ZeroPost
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-6">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-6 ink">
|
||||
Эксперимент: блог, который ведёт ИИ
|
||||
</h1>
|
||||
<div className="prose prose-invert prose-lg max-w-none">
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p>
|
||||
ZeroPost — это два связанных проекта: <strong>публичный блог</strong>, который ты сейчас читаешь, и <strong>сервис</strong> для ведения Telegram-каналов с помощью ИИ.
|
||||
</p>
|
||||
|
||||
@@ -30,7 +30,6 @@ export default async function ArticlePage({ params }) {
|
||||
const article = await getArticle(slug);
|
||||
if (!article) notFound();
|
||||
|
||||
// Убираю H1 из контента — он уже идёт в заголовке страницы
|
||||
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
|
||||
const html = renderMarkdown(contentWithoutH1);
|
||||
|
||||
@@ -38,7 +37,7 @@ export default async function ArticlePage({ params }) {
|
||||
<>
|
||||
<Header />
|
||||
<article className="container-narrow pt-10 pb-16">
|
||||
<Link href="/" className="btn-ghost text-sm mb-6 -ml-2">
|
||||
<Link href="/" className="btn btn-ghost text-sm mb-6 -ml-2">
|
||||
<ArrowLeft className="w-4 h-4" /> Все статьи
|
||||
</Link>
|
||||
|
||||
@@ -48,11 +47,11 @@ export default async function ArticlePage({ params }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="font-serif text-3xl sm:text-5xl font-bold leading-tight mb-5 tracking-tight">
|
||||
<h1 className="font-serif text-3xl sm:text-5xl font-bold leading-tight mb-5 tracking-tight ink">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-mute pb-8 mb-8 border-b border-border/60">
|
||||
<div className="flex items-center gap-4 text-sm mute pb-8 mb-8 border-b-soft">
|
||||
<span>{article.author}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(article.published_at)}</span>
|
||||
@@ -67,13 +66,13 @@ export default async function ArticlePage({ params }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="prose prose-invert prose-lg max-w-none font-serif prose-headings:font-sans"
|
||||
className="prose prose-lg max-w-none font-serif prose-headings:font-sans"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
<div className="mt-16 pt-8 border-t border-border/60 text-center">
|
||||
<p className="text-mute mb-4">Хочешь такой же блог или канал в Telegram?</p>
|
||||
<a href="https://app.zeropost.ru" className="btn-primary">
|
||||
<div className="mt-16 pt-8 border-t-soft text-center">
|
||||
<p className="mute mb-4">Хочешь такой же блог или канал в Telegram?</p>
|
||||
<a href="https://app.zeropost.ru" className="btn btn-primary">
|
||||
Открыть ZeroPost
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+81
-7
@@ -2,18 +2,92 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* === Theme tokens === */
|
||||
:root {
|
||||
--bg: 250 250 249; /* почти белый, тёплый */
|
||||
--surface: 255 255 255; /* карточки */
|
||||
--surface-2: 245 245 244; /* приглушённый фон */
|
||||
--border: 231 229 228;
|
||||
--ink: 28 25 23; /* основной текст */
|
||||
--mute: 120 113 108; /* приглушённый текст */
|
||||
--accent: 16 185 129; /* emerald-500 */
|
||||
--accent-2: 5 150 105; /* emerald-600 */
|
||||
--accent-soft: 209 250 229; /* emerald-100 */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: 10 10 10;
|
||||
--surface: 20 20 20;
|
||||
--surface-2: 28 28 28;
|
||||
--border: 42 42 42;
|
||||
--ink: 229 231 235;
|
||||
--mute: 156 163 175;
|
||||
--accent: 16 185 129;
|
||||
--accent-2: 52 211 153;
|
||||
--accent-soft: 6 78 59;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html { @apply bg-bg text-ink; }
|
||||
html {
|
||||
background: rgb(var(--bg));
|
||||
color: rgb(var(--ink));
|
||||
}
|
||||
body { @apply font-sans antialiased; }
|
||||
::selection { @apply bg-accent/30; }
|
||||
::selection { background: rgb(var(--accent) / 0.25); }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn { @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed; }
|
||||
.btn-primary { @apply btn bg-accent text-black hover:bg-accent2; }
|
||||
.btn-ghost { @apply btn text-mute hover:bg-surface hover:text-white; }
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: rgb(var(--accent));
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: rgb(var(--accent-2)); }
|
||||
|
||||
.btn-ghost {
|
||||
color: rgb(var(--mute));
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: rgb(var(--surface-2));
|
||||
color: rgb(var(--ink));
|
||||
}
|
||||
|
||||
.container-narrow { @apply max-w-3xl mx-auto px-4; }
|
||||
.container-wide { @apply max-w-6xl mx-auto px-4; }
|
||||
.article-card { @apply bg-surface border border-border rounded-2xl p-6 hover:border-accent/40 transition-colors; }
|
||||
.tag { @apply inline-block text-xs px-2.5 py-1 rounded-full bg-surface2 text-mute hover:bg-surface hover:text-white transition-colors; }
|
||||
|
||||
.article-card {
|
||||
background: rgb(var(--surface));
|
||||
border: 1px solid rgb(var(--border));
|
||||
@apply rounded-2xl p-6 transition-all duration-200;
|
||||
}
|
||||
.article-card:hover {
|
||||
border-color: rgb(var(--accent) / 0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -10px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
.dark .article-card:hover {
|
||||
box-shadow: 0 10px 25px -10px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
@apply text-xs px-2.5 py-1 rounded-full transition-colors;
|
||||
background: rgb(var(--surface-2));
|
||||
color: rgb(var(--mute));
|
||||
}
|
||||
.tag:hover {
|
||||
background: rgb(var(--accent-soft));
|
||||
color: rgb(var(--accent-2));
|
||||
}
|
||||
|
||||
.surface { background: rgb(var(--surface)); }
|
||||
.surface-2 { background: rgb(var(--surface-2)); }
|
||||
.ink { color: rgb(var(--ink)); }
|
||||
.mute { color: rgb(var(--mute)); }
|
||||
.accent { color: rgb(var(--accent)); }
|
||||
.border-soft { border-color: rgb(var(--border)); }
|
||||
.border-b-soft { border-bottom: 1px solid rgb(var(--border)); }
|
||||
.border-t-soft { border-top: 1px solid rgb(var(--border)); }
|
||||
}
|
||||
|
||||
+13
-1
@@ -14,10 +14,22 @@ export const metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
// Защита от FOUC: ставим тему до первого рендера
|
||||
const themeInitScript = `
|
||||
(function() {
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||
} catch(e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="container-narrow pt-20 pb-20 text-center">
|
||||
<div className="text-7xl font-bold mb-4 ink">404</div>
|
||||
<p className="text-xl mute mb-8">
|
||||
Такой страницы нет — но это не повод грустить
|
||||
</p>
|
||||
<Link href="/" className="btn btn-primary">
|
||||
<ArrowLeft className="w-4 h-4" /> На главную
|
||||
</Link>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+13
-10
@@ -28,22 +28,25 @@ export default async function HomePage() {
|
||||
{/* Hero */}
|
||||
<section className="container-wide pt-12 pb-10 sm:pt-20 sm:pb-16">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 text-xs text-accent bg-accent/10 border border-accent/20 px-3 py-1.5 rounded-full mb-6">
|
||||
<div
|
||||
className="inline-flex items-center gap-2 text-xs accent px-3 py-1.5 rounded-full mb-6"
|
||||
style={{ background: 'rgb(var(--accent) / 0.1)', border: '1px solid rgb(var(--accent) / 0.2)' }}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Блог, который ведёт ИИ
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight leading-tight mb-5">
|
||||
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight leading-tight mb-5 ink">
|
||||
Практический ИИ.<br />
|
||||
<span className="text-mute">Без воды и хайпа.</span>
|
||||
<span className="mute">Без воды и хайпа.</span>
|
||||
</h1>
|
||||
<p className="text-lg text-mute mb-8 max-w-2xl">
|
||||
<p className="text-lg mute mb-8 max-w-2xl">
|
||||
Промпты, кейсы, инструменты и разборы. Всё пишет ИИ — кроме редакторских заметок. Если хочешь так же вести свой Telegram-канал — попробуй наш сервис.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="#articles" className="btn-primary">
|
||||
<Link href="#articles" className="btn btn-primary">
|
||||
Читать статьи <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<a href="https://app.zeropost.ru" className="btn-ghost">
|
||||
<a href="https://app.zeropost.ru" className="btn btn-ghost">
|
||||
Получить ассистента
|
||||
</a>
|
||||
</div>
|
||||
@@ -60,7 +63,7 @@ export default async function HomePage() {
|
||||
{/* Rest */}
|
||||
{rest.length > 0 && (
|
||||
<section className="container-wide pb-12">
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest text-mute mb-5">
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-5">
|
||||
Последние материалы
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
@@ -71,18 +74,18 @@ export default async function HomePage() {
|
||||
|
||||
{articles.length === 0 && (
|
||||
<section className="container-wide py-20 text-center">
|
||||
<p className="text-mute">Скоро здесь появятся первые статьи. ИИ уже работает над ними.</p>
|
||||
<p className="mute">Скоро здесь появятся первые статьи. ИИ уже работает над ними.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<section className="container-wide pb-12">
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest text-mute mb-4">Темы</h2>
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-4">Темы</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map(t => (
|
||||
<Link key={t.tag} href={`/tag/${encodeURIComponent(t.tag)}`} className="tag">
|
||||
#{t.tag} <span className="text-mute/60">({t.cnt})</span>
|
||||
#{t.tag} <span style={{ opacity: 0.6 }}>({t.cnt})</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,13 +21,13 @@ export default async function TagPage({ params }) {
|
||||
<>
|
||||
<Header />
|
||||
<main className="container-wide pt-10 pb-16">
|
||||
<Link href="/" className="btn-ghost text-sm mb-4 -ml-2">
|
||||
<Link href="/" className="btn btn-ghost text-sm mb-4 -ml-2">
|
||||
<ArrowLeft className="w-4 h-4" /> Все статьи
|
||||
</Link>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-2">#{tag}</h1>
|
||||
<p className="text-mute mb-8">{articles.length} {articles.length === 1 ? 'материал' : 'материалов'}</p>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-2 ink">#{tag}</h1>
|
||||
<p className="mute mb-8">{articles.length} {articles.length === 1 ? 'материал' : 'материалов'}</p>
|
||||
{articles.length === 0 ? (
|
||||
<p className="text-mute">Пока пусто.</p>
|
||||
<p className="mute">Пока пусто.</p>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{articles.map(a => <ArticleCard key={a.id} article={a} />)}
|
||||
|
||||
Reference in New Issue
Block a user