Supabase was integrated to store articles, article editor is standardized editor using react component
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -121,27 +121,6 @@ export default function ContactPage({ profile, error }) {
|
||||
<p>I’m 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user