Supabase was integrated to store articles, article editor is standardized editor using react component

This commit is contained in:
StormRunner06106
2026-09-12 01:39:07 -07:00
parent 14d1dbbb07
commit 3ed741d6d2
18 changed files with 1031 additions and 131 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<meta name="theme-color" content="#f7f8f4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2230%22 fill=%22%232f5147%22/><text x=%2250%22 y=%2264%22 text-anchor=%22middle%22 font-size=%2244%22 fill=%22white%22 font-family=%22Arial%22>AH</text></svg>" />
<title>Alex Herlan — Software Engineer</title>
</head>
+14
View File
@@ -8,6 +8,7 @@
"name": "alex-herlan-portfolio",
"version": "1.0.0",
"dependencies": {
"@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
@@ -629,6 +630,19 @@
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz",
"integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.31.3"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
+23 -19
View File
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import { getProfile } from "./api";
import Layout from "./components/Layout";
import AboutPage from "./pages/AboutPage";
import BlogPage from "./pages/BlogPage";
import BlogPostPage from "./pages/BlogPostPage";
import ContactPage from "./pages/ContactPage";
import ExperiencePage from "./pages/ExperiencePage";
import JournalAdminPage from "./pages/JournalAdminPage";
import NotFoundPage from "./pages/NotFoundPage";
import SkillsPage from "./pages/SkillsPage";
import { LoadingState } from "./components/Status";
const BlogPostPage = lazy(() => import("./pages/BlogPostPage"));
const JournalAdminPage = lazy(() => import("./pages/JournalAdminPage"));
function ScrollToTop() {
const { pathname } = useLocation();
@@ -38,22 +40,24 @@ export default function App() {
return (
<Layout profile={profile}>
<ScrollToTop />
<Routes>
<Route
path="/"
element={<AboutPage error={profileError} profile={profile} />}
/>
<Route path="/experience" element={<ExperiencePage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/manage" element={<JournalAdminPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route
path="/contact"
element={<ContactPage error={profileError} profile={profile} />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Suspense fallback={<div className="container content-page"><LoadingState label="Opening page" /></div>}>
<Routes>
<Route
path="/"
element={<AboutPage error={profileError} profile={profile} />}
/>
<Route path="/experience" element={<ExperiencePage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/manage" element={<JournalAdminPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route
path="/contact"
element={<ContactPage error={profileError} profile={profile} />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</Layout>
);
}
+8 -2
View File
@@ -4,7 +4,7 @@ async function request(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"Content-Type": options.body instanceof File ? "application/octet-stream" : "application/json",
...options.headers,
},
});
@@ -13,7 +13,7 @@ async function request(path, options = {}) {
let detail = "Something went wrong. Please try again.";
try {
const body = await response.json();
detail = body.detail ?? detail;
detail = typeof body.detail === "string" ? body.detail : detail;
} catch {
// Keep the friendly fallback when a proxy or server returns non-JSON.
}
@@ -30,6 +30,12 @@ export const getExperience = (signal) => request("/api/experience", { signal });
export const getSkills = (signal) => request("/api/skills", { signal });
export const getPosts = (signal) => request("/api/posts", { signal });
export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { signal });
export const mediaUrl = (media) => `${API_BASE}${media.url}`;
export const uploadMedia = (file, purpose, token) => request(`/api/uploads?name=${encodeURIComponent(file.name)}&purpose=${purpose}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: file,
});
export const loginAdmin = (password) =>
request("/api/auth/login", {
method: "POST",
+118
View File
@@ -0,0 +1,118 @@
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { TextStyleKit } from "@tiptap/extension-text-style";
import Placeholder from "@tiptap/extension-placeholder";
import { useEffect } from "react";
const extensions = [
StarterKit.configure({ heading: { levels: [2, 3] } }),
TextStyleKit.configure({
backgroundColor: false,
color: false,
fontFamily: false,
lineHeight: false,
}),
Placeholder.configure({
placeholder: "Start with the idea you want the reader to keep…",
}),
];
function ToolbarButton({ active = false, children, label, onClick }) {
return (
<button
aria-label={label}
className={active ? "is-active" : ""}
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
);
}
export default function RichTextEditor({ onChange }) {
const editor = useEditor({
extensions,
content: "",
editorProps: { attributes: { "aria-label": "Article body" } },
onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getJSON(), currentEditor.getText().trim());
},
});
const state = useEditorState({
editor,
selector: ({ editor: currentEditor }) => ({
bold: currentEditor?.isActive("bold") ?? false,
italic: currentEditor?.isActive("italic") ?? false,
underline: currentEditor?.isActive("underline") ?? false,
heading2: currentEditor?.isActive("heading", { level: 2 }) ?? false,
heading3: currentEditor?.isActive("heading", { level: 3 }) ?? false,
bulletList: currentEditor?.isActive("bulletList") ?? false,
orderedList: currentEditor?.isActive("orderedList") ?? false,
fontSize: currentEditor?.getAttributes("textStyle").fontSize ?? "",
}),
});
if (!editor) return <div className="editor-loading">Preparing editor</div>;
return (
<div className="rich-editor">
<div className="rich-editor__toolbar" aria-label="Text formatting">
<div className="editor-tool-group">
<ToolbarButton active={state?.bold} label="Bold" onClick={() => editor.chain().focus().toggleBold().run()}><strong>B</strong></ToolbarButton>
<ToolbarButton active={state?.italic} label="Italic" onClick={() => editor.chain().focus().toggleItalic().run()}><em>I</em></ToolbarButton>
<ToolbarButton active={state?.underline} label="Underline" onClick={() => editor.chain().focus().toggleUnderline().run()}><u>U</u></ToolbarButton>
</div>
<div className="editor-tool-group">
<ToolbarButton active={state?.heading2} label="Heading" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</ToolbarButton>
<ToolbarButton active={state?.heading3} label="Subheading" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolbarButton>
</div>
<div className="editor-tool-group">
<ToolbarButton active={state?.bulletList} label="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}> List</ToolbarButton>
<ToolbarButton active={state?.orderedList} label="Numbered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}>1. List</ToolbarButton>
</div>
<label className="editor-size">
<span className="sr-only">Font size</span>
<select
aria-label="Font size"
onChange={(event) => {
const size = event.target.value;
const chain = editor.chain().focus();
if (size) chain.setFontSize(size).run();
else chain.unsetFontSize().run();
}}
value={state?.fontSize ?? ""}
>
<option value="">Normal</option>
<option value="14px">Small</option>
<option value="18px">Medium</option>
<option value="22px">Large</option>
<option value="28px">Extra large</option>
</select>
</label>
<div className="editor-tool-group editor-tool-group--history">
<ToolbarButton label="Undo" onClick={() => editor.chain().focus().undo().run()}></ToolbarButton>
<ToolbarButton label="Redo" onClick={() => editor.chain().focus().redo().run()}></ToolbarButton>
</div>
</div>
<EditorContent editor={editor} />
</div>
);
}
export function RichTextArticle({ content }) {
const editor = useEditor({
extensions,
content,
editable: false,
});
useEffect(() => {
if (editor) editor.commands.setContent(content);
}, [content, editor]);
if (!editor) return null;
return <EditorContent className="rich-article" editor={editor} />;
}
+7 -5
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { getPost } from "../api";
import Icon from "../components/Icon";
import { RichTextArticle } from "../components/RichTextEditor";
import { ErrorState, LoadingState } from "../components/Status";
function formatDate(date) {
@@ -58,20 +59,21 @@ export default function BlogPostPage() {
</div>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
<div className="article-byline">
<span className="mini-avatar">AH</span>
<span><strong>Alex Herlan</strong><small>{formatDate(post.published_at)} · {post.read_time} min read</small></span>
<div className="article-byline article-byline--simple">
<Icon name="calendar" size={16} />
<span>{formatDate(post.published_at)}</span>
<span>{post.read_time} min read</span>
</div>
</div>
</header>
<div className="article-container article-content">
{post.content.map((section) => (
{Array.isArray(post.content) ? post.content.map((section) => (
<section key={section.heading}>
<h2>{section.heading}</h2>
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</section>
))}
)) : <RichTextArticle content={post.content} />}
<div className="article-end">
<Icon name="spark" />
<p>Thanks for reading.</p>
+23 -21
View File
@@ -121,27 +121,6 @@ export default function ContactPage({ profile, error }) {
<p>Im especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p>
</div>
<div className="culture-card">
<p className="eyebrow">Beyond the build</p>
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
<p>Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.</p>
<div className="culture-links">
{interests.map((interest) => (
<a
className={`culture-link culture-link--${interest.kind}`}
href={interest.href}
key={interest.kind}
rel="noreferrer"
target="_blank"
>
<span className="culture-link__icon"><Icon name={interest.kind} /></span>
<span><small>{interest.note}</small><strong>{interest.label}</strong></span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
</div>
</div>
<div className="social-list">
<p className="eyebrow">Find me here</p>
{socials.map((social) => (
@@ -158,6 +137,29 @@ export default function ContactPage({ profile, error }) {
</div>
</aside>
</div>
<div className="culture-card culture-card--wide reveal">
<div className="culture-intro">
<p className="eyebrow">Beyond the build</p>
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
<p>Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.</p>
</div>
<div className="culture-links">
{interests.map((interest) => (
<a
className={`culture-link culture-link--${interest.kind}`}
href={interest.href}
key={interest.kind}
rel="noreferrer"
target="_blank"
>
<span className="culture-link__icon"><Icon name={interest.kind} /></span>
<span><small>{interest.note}</small><strong>{interest.label}</strong></span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
</div>
</div>
</section>
);
}
+90 -28
View File
@@ -3,20 +3,32 @@ import { useNavigate } from "react-router-dom";
import { createPost, getAdminSession, loginAdmin } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import RichTextEditor from "../components/RichTextEditor";
import { LoadingState } from "../components/Status";
const TOKEN_KEY = "alex-journal-admin";
const today = new Date().toISOString().slice(0, 10);
const emptyDocument = { type: "doc", content: [{ type: "paragraph" }] };
const suggestedTopics = [
"AI",
"React",
"FastAPI",
"Supabase",
"Python",
"Cloud",
"DevOps",
"Product",
"Reliability",
"Security",
];
const initialPost = {
title: "",
excerpt: "",
published_at: today,
read_time: 5,
tags: "AI, Engineering",
tags: ["AI"],
accent: "mint",
section_heading: "The idea",
body: "",
};
export default function JournalAdminPage() {
@@ -25,6 +37,9 @@ export default function JournalAdminPage() {
const [checking, setChecking] = useState(Boolean(token));
const [password, setPassword] = useState("");
const [post, setPost] = useState(initialPost);
const [article, setArticle] = useState(emptyDocument);
const [articleText, setArticleText] = useState("");
const [customTopic, setCustomTopic] = useState("");
const [status, setStatus] = useState({ type: "idle", message: "" });
useEffect(() => {
@@ -72,20 +87,45 @@ export default function JournalAdminPage() {
setPost((current) => ({ ...current, [event.target.name]: event.target.value }));
}
function toggleTopic(topic) {
setPost((current) => {
if (current.tags.includes(topic)) {
return { ...current, tags: current.tags.filter((item) => item !== topic) };
}
if (current.tags.length >= 6) {
setStatus({ type: "error", message: "Choose up to six topics." });
return current;
}
return { ...current, tags: [...current.tags, topic] };
});
}
function addCustomTopic() {
const topic = customTopic.trim();
if (!topic || post.tags.includes(topic)) return;
if (post.tags.length >= 6) {
setStatus({ type: "error", message: "Choose up to six topics." });
return;
}
setPost((current) => ({ ...current, tags: [...current.tags, topic] }));
setCustomTopic("");
}
async function publish(event) {
event.preventDefault();
const paragraphs = post.body
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
if (!post.tags.length) {
setStatus({ type: "error", message: "Choose at least one topic." });
return;
}
if (articleText.length < 40) {
setStatus({ type: "error", message: "Write at least 40 characters before publishing." });
return;
}
const payload = {
title: post.title,
excerpt: post.excerpt,
published_at: post.published_at,
...post,
read_time: Number(post.read_time),
tags: post.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
accent: post.accent,
content: [{ heading: post.section_heading, paragraphs }],
content: article,
};
setStatus({ type: "sending", message: "Publishing…" });
@@ -107,7 +147,7 @@ export default function JournalAdminPage() {
<PageHeader
eyebrow="Journal studio"
title="Publish a field note."
description="A small private writing room for adding new articles to the JSON journal."
description="A focused writing room with rich-text editing and Supabase-ready storage."
aside={token ? <span className="publisher-badge"><span /> Authenticated</span> : null}
/>
@@ -145,7 +185,7 @@ export default function JournalAdminPage() {
<div className="publisher-toolbar">
<div>
<p className="eyebrow">New article</p>
<p>Paragraphs are separated by a blank line.</p>
<p>Write, format, choose the topics, and publish from one clean workspace.</p>
</div>
<button className="text-button" onClick={signOut} type="button"><Icon name="logout" size={16} /> Sign out</button>
</div>
@@ -181,21 +221,43 @@ export default function JournalAdminPage() {
</label>
</div>
<label className="publisher-field publisher-field--wide">
<span>Topics</span>
<input name="tags" onChange={updatePost} placeholder="AI, React, Systems" required value={post.tags} />
<small>Separate up to six topics with commas.</small>
</label>
<fieldset className="topic-picker">
<legend>Topics <span>{post.tags.length}/6 selected</span></legend>
<div className="topic-options">
{suggestedTopics.map((topic) => (
<button
aria-pressed={post.tags.includes(topic)}
className={post.tags.includes(topic) ? "is-active" : ""}
key={topic}
onClick={() => toggleTopic(topic)}
type="button"
>
{post.tags.includes(topic) ? "✓ " : "+ "}{topic}
</button>
))}
</div>
<div className="custom-topic">
<input
maxLength="40"
onChange={(event) => setCustomTopic(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addCustomTopic();
}
}}
placeholder="Add another topic"
value={customTopic}
/>
<button onClick={addCustomTopic} type="button">Add topic</button>
</div>
</fieldset>
<label className="publisher-field publisher-field--wide">
<span>Section heading</span>
<input minLength="3" name="section_heading" onChange={updatePost} required value={post.section_heading} />
</label>
<label className="publisher-field publisher-field--wide">
<div className="publisher-field publisher-field--wide">
<span>Article</span>
<textarea minLength="40" name="body" onChange={updatePost} placeholder="Write the article here…" required rows="15" value={post.body} />
</label>
<RichTextEditor onChange={(content, text) => { setArticle(content); setArticleText(text); }} />
<small>{articleText.length} characters · Use headings to break longer pieces into sections.</small>
</div>
<div className="publisher-submit">
<button className="button button--primary" disabled={status.type === "sending"} type="submit">
+306 -35
View File
@@ -25,7 +25,7 @@
--container: 1160px;
--gutter: clamp(24px, 8vw, 150px);
color: var(--ink);
font-family: "Roboto Flex", Roboto, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: Roboto, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
@@ -182,7 +182,7 @@ button {
padding: 10px 15px;
border-radius: 13px;
color: var(--ink-soft);
font-size: 13px;
font-size: 14px;
font-weight: 620;
transition: color 160ms ease, background 160ms ease, transform 160ms ease;
}
@@ -335,7 +335,7 @@ button {
max-width: 660px;
margin: 22px 0 14px;
color: #3e514a;
font-size: clamp(18px, 1.7vw, 24px);
font-size: clamp(20px, 1.8vw, 26px);
font-weight: 540;
letter-spacing: -0.025em;
line-height: 1.35;
@@ -345,7 +345,7 @@ button {
max-width: 680px;
margin: 0;
color: var(--ink-soft);
font-size: clamp(14px, 1.1vw, 16px);
font-size: clamp(16px, 1.18vw, 18px);
line-height: 1.75;
}
@@ -367,7 +367,7 @@ button {
position: relative;
padding-left: 13px;
color: var(--ink-faint);
font-size: 11px;
font-size: 12px;
font-weight: 680;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -500,7 +500,7 @@ button {
max-width: 660px;
margin: 19px 0 0;
color: var(--ink-soft);
font-size: 16px;
font-size: 18px;
line-height: 1.75;
}
@@ -713,7 +713,7 @@ button {
.company-line {
margin: 6px 0 0;
color: var(--ink-soft);
font-size: 13px;
font-size: 14px;
font-weight: 630;
}
@@ -764,7 +764,7 @@ button {
margin-top: 0;
padding-top: 1px;
color: var(--ink-soft);
font-size: 14px;
font-size: 16px;
line-height: 1.75;
}
@@ -781,7 +781,7 @@ button {
grid-template-columns: 22px 1fr;
gap: 10px;
color: var(--ink-soft);
font-size: 13px;
font-size: 14px;
line-height: 1.65;
}
@@ -904,7 +904,7 @@ button {
.principles-list p {
margin: 0;
font-size: 12px;
font-size: 14px;
font-weight: 620;
line-height: 1.55;
}
@@ -973,7 +973,7 @@ button {
min-height: 43px;
margin: 0;
color: var(--ink-soft);
font-size: 12px;
font-size: 14px;
line-height: 1.6;
}
@@ -999,7 +999,7 @@ button {
.issue-count span {
color: var(--ink-faint);
font-family: "Roboto Flex", Roboto, ui-sans-serif, sans-serif;
font-family: Roboto, ui-sans-serif, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
@@ -1174,7 +1174,7 @@ button {
align-items: center;
gap: 16px;
color: var(--ink-faint);
font-size: 9px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
@@ -1201,7 +1201,7 @@ button {
.post-card__body > p {
margin: 0;
color: var(--ink-soft);
font-size: 12px;
font-size: 14px;
line-height: 1.65;
}
@@ -1334,6 +1334,21 @@ button {
margin-top: 29px;
}
.article-byline--simple {
gap: 10px;
color: var(--ink-soft);
font-size: 11px;
font-weight: 720;
letter-spacing: 0.045em;
text-transform: uppercase;
}
.article-byline--simple span + span::before {
margin-right: 10px;
color: rgba(36, 52, 47, 0.35);
content: "·";
}
.mini-avatar {
display: grid;
width: 38px;
@@ -1387,7 +1402,7 @@ button {
margin: 0;
color: #465751;
font-family: Georgia, "Times New Roman", serif;
font-size: 18px;
font-size: 19px;
line-height: 1.85;
}
@@ -1492,7 +1507,7 @@ button {
outline: 0;
color: var(--ink);
background: #fbfcfa;
font-size: 13px;
font-size: 15px;
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
@@ -1574,7 +1589,7 @@ button {
.contact-note p {
margin: 0;
color: var(--ink-soft);
font-size: 12px;
font-size: 14px;
line-height: 1.7;
}
@@ -1625,7 +1640,7 @@ button {
.social-list strong {
overflow: hidden;
font-size: 11px;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1727,6 +1742,23 @@ button {
box-shadow: 0 12px 34px rgba(75, 67, 95, 0.06);
}
.culture-card--wide {
margin-top: 20px;
padding: clamp(28px, 4vw, 42px);
}
.culture-intro {
display: grid;
grid-template-columns: minmax(240px, 0.8fr) minmax(320px, 1.2fr);
align-items: end;
gap: 8px 48px;
margin-bottom: 28px;
}
.culture-intro .eyebrow {
grid-column: 1 / -1;
}
.culture-card .eyebrow {
margin-bottom: 13px;
color: var(--lavender-deep);
@@ -1735,7 +1767,7 @@ button {
.culture-card h2 {
margin: 0;
color: var(--ink);
font-family: "Roboto Flex", Roboto, ui-sans-serif, sans-serif;
font-family: Roboto, ui-sans-serif, sans-serif;
font-size: 18px;
font-weight: 520;
letter-spacing: -0.025em;
@@ -1746,21 +1778,22 @@ button {
font-weight: 850;
}
.culture-card > p:not(.eyebrow) {
margin: 11px 0 20px;
.culture-intro > p:not(.eyebrow) {
margin: 0 0 2px;
color: var(--ink-soft);
font-size: 12px;
line-height: 1.65;
font-size: 15px;
line-height: 1.7;
}
.culture-links {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 9px;
}
.culture-link {
display: grid;
min-height: 58px;
min-height: 72px;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
@@ -1804,7 +1837,7 @@ button {
}
.culture-link strong {
font-size: 12px;
font-size: 14px;
font-style: italic;
font-weight: 850;
}
@@ -1897,7 +1930,7 @@ button {
outline: 0;
color: var(--ink);
background: #fbfcfa;
font-size: 13px;
font-size: 15px;
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
@@ -1970,6 +2003,226 @@ button {
padding-top: 4px;
}
.topic-picker {
display: grid;
grid-column: 1 / -1;
gap: 13px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.topic-picker legend {
width: 100%;
margin-bottom: 5px;
color: var(--ink-soft);
font-size: 11px;
font-weight: 760;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.topic-picker legend span {
float: right;
color: var(--ink-faint);
font-size: 10px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
.topic-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.topic-options button {
min-height: 38px;
padding-inline: 13px;
border: 0;
border-radius: 12px;
color: var(--ink-soft);
background: #f0f3ef;
font-size: 12px;
font-weight: 680;
cursor: pointer;
}
.topic-options button.is-active {
color: white;
background: var(--sage-deep);
box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15);
}
.custom-topic {
display: flex;
max-width: 430px;
gap: 8px;
}
.custom-topic input {
min-width: 0;
min-height: 44px;
flex: 1;
padding-inline: 14px;
border: 1px solid rgba(54, 78, 70, 0.13);
border-radius: 13px;
outline: 0;
color: var(--ink);
background: #fbfcfa;
font-size: 14px;
}
.custom-topic input:focus {
border-color: rgba(47, 81, 71, 0.43);
box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07);
}
.custom-topic button {
flex: 0 0 auto;
padding-inline: 16px;
border: 0;
border-radius: 13px;
color: var(--sage-deep);
background: var(--mint);
font-size: 12px;
font-weight: 760;
cursor: pointer;
}
.rich-editor {
overflow: hidden;
border: 1px solid rgba(54, 78, 70, 0.14);
border-radius: 19px;
background: #fcfdfb;
transition: border 160ms ease, box-shadow 160ms ease;
}
.rich-editor:focus-within {
border-color: rgba(47, 81, 71, 0.42);
box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07);
}
.rich-editor__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: #f2f5f1;
}
.editor-tool-group {
display: flex;
gap: 3px;
padding-right: 7px;
border-right: 1px solid var(--line);
}
.editor-tool-group button {
min-width: 34px;
height: 34px;
padding-inline: 8px;
border: 0;
border-radius: 10px;
color: var(--ink-soft);
background: transparent;
font-size: 12px;
cursor: pointer;
}
.editor-tool-group button:hover,
.editor-tool-group button.is-active {
color: var(--ink);
background: white;
box-shadow: 0 5px 15px rgba(46, 65, 58, 0.08);
}
.editor-size select {
width: auto;
min-height: 34px;
padding-inline: 10px 28px;
border: 0;
border-radius: 10px;
background: white;
font-size: 12px;
}
.editor-tool-group--history {
margin-left: auto;
padding-right: 0;
border-right: 0;
}
.rich-editor .tiptap {
min-height: 430px;
padding: 28px;
outline: 0;
color: #3f514a;
font-size: 18px;
line-height: 1.8;
}
.rich-editor .tiptap p.is-editor-empty:first-child::before {
height: 0;
float: left;
color: #9ba5a1;
content: attr(data-placeholder);
pointer-events: none;
}
.rich-editor .tiptap h2,
.rich-editor .tiptap h3,
.rich-article .tiptap h2,
.rich-article .tiptap h3 {
color: var(--ink);
font-family: Georgia, "Times New Roman", serif;
font-weight: 400;
letter-spacing: -0.035em;
line-height: 1.15;
}
.rich-editor .tiptap h2 { margin: 30px 0 13px; font-size: 34px; }
.rich-editor .tiptap h3 { margin: 25px 0 11px; font-size: 26px; }
.rich-editor .tiptap p { margin: 0 0 17px; }
.rich-editor .tiptap em,
.rich-article .tiptap em { font-style: italic; }
.rich-editor .tiptap u,
.rich-article .tiptap u { text-decoration-thickness: 1px; text-underline-offset: 3px; }
.rich-editor .tiptap ul,
.rich-editor .tiptap ol { padding-left: 26px; }
.rich-article .tiptap {
color: #465751;
font-family: Georgia, "Times New Roman", serif;
font-size: 19px;
line-height: 1.85;
}
.rich-article .tiptap h2 { margin: 55px 0 20px; font-size: clamp(30px, 4vw, 42px); }
.rich-article .tiptap h2:first-child { margin-top: 0; }
.rich-article .tiptap h3 { margin: 38px 0 16px; font-size: clamp(24px, 3vw, 32px); }
.rich-article .tiptap p { margin: 0 0 24px; }
.rich-article .tiptap ul,
.rich-article .tiptap ol { margin: 0 0 26px; padding-left: 28px; }
.rich-article .tiptap li { margin-bottom: 8px; }
.rich-article .tiptap blockquote {
margin: 32px 0;
padding: 20px 25px;
border-left: 3px solid #8fa99e;
border-radius: 0 16px 16px 0;
background: var(--mint);
}
.editor-loading {
min-height: 220px;
padding: 25px;
color: var(--ink-faint);
}
@media (max-width: 1120px) {
:root {
--gutter: clamp(24px, 5vw, 70px);
@@ -2060,10 +2313,6 @@ button {
.contact-aside {
grid-template-columns: 1fr 1fr;
}
.contact-aside .social-list {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
@@ -2230,6 +2479,20 @@ button {
align-items: flex-start;
}
.culture-intro {
grid-template-columns: 1fr;
gap: 10px;
}
.culture-links {
grid-template-columns: 1fr;
}
.rich-editor .tiptap {
min-height: 350px;
padding: 22px;
}
.form-footer {
align-items: stretch;
flex-direction: column;
@@ -2295,11 +2558,11 @@ button {
}
.about-copy h2 {
font-size: 18px;
font-size: 20px;
}
.about-summary {
font-size: 14px;
font-size: 16px;
}
.hero-actions {
@@ -2324,7 +2587,7 @@ button {
}
.page-description {
font-size: 14px;
font-size: 16px;
}
.timeline-card__header {
@@ -2370,7 +2633,15 @@ button {
}
.article-content section p {
font-size: 17px;
font-size: 18px;
}
.custom-topic {
max-width: none;
}
.editor-tool-group--history {
margin-left: 0;
}
}
@@ -2392,7 +2663,7 @@ button {
}
.about-summary {
font-size: 13px;
font-size: 14px;
line-height: 1.62;
}