Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/pages/blog/[slug].astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import { getCollection, getEntries, render } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import { Image } from 'astro:assets';
import BaseLayout from '@/layouts/BaseLayout.astro';
import { mdxComponents } from '@/components/MDXComponents.tsx';
import ArticleActions from '@/components/ArticleActions.tsx';
Expand Down Expand Up @@ -147,6 +148,24 @@ const breadcrumbJsonLd = {
</div>
</div>

{
cover && (
<figure class="article-cover">
{typeof cover === 'string' ? (
<img src={cover} alt="" loading="eager" />
) : (
<Image
src={cover}
alt=""
widths={[620, 960, 1440]}
sizes="(min-width: 960px) 960px, 100vw"
loading="eager"
/>
)}
</figure>
)
}

<div class="article-body">
<Content components={mdxComponents} />
</div>
Expand Down
12 changes: 12 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,18 @@ a.hashtag:hover {
margin: 0 0 32px;
text-wrap: pretty;
}
.article-cover {
margin: 0 auto 40px;
max-width: clamp(var(--text-w), 72vw, var(--text-w-max));
padding: 0 var(--sp-5);
}
.article-cover img {
width: 100%;
height: auto;
border-radius: var(--radius-lg, 10px);
border: 1px solid var(--line);
display: block;
}
.article-body {
font-family: var(--font-read);
font-size: clamp(18px, 1.7vw, 20px);
Expand Down
124 changes: 123 additions & 1 deletion src/write/WritePortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import { MetaForm, type Option } from './meta/MetaForm';
import { serializePost, type PostMeta, type SBlock, type TableStyle } from './serialize/toMdx';
import { validate } from './serialize/validate';
import { buildZip } from './serialize/toZip';
import { allAssets } from './storage/assets';
import { buildSource } from './serialize/source';
import { fetchExisting } from './serialize/fetchExisting';
import { allAssets, clearAssets } from './storage/assets';
import {
clearDraft,
clearStoredAssets,
Expand Down Expand Up @@ -64,6 +66,25 @@ type Props = {
contactEmail: string;
};

function collectImages(blocks: SBlock[]): string[] {
const out: string[] = [];
const walk = (list: SBlock[]) => {
for (const b of list) {
if (b.type === 'figure' && b.props.fileName) out.push(String(b.props.fileName));
if (b.type === 'gallery') {
try {
out.push(...(JSON.parse(String(b.props.fileNames || '[]')) as string[]));
} catch {
// ignore a malformed gallery — it just won't offer cover options
}
}
if (b.children?.length) walk(b.children);
}
};
walk(blocks);
return [...new Set(out.filter(Boolean))];
}

function isEmptyDraft(meta: PostMeta, blocks: SBlock[]): boolean {
const metaEmpty =
!meta.title.trim() && !meta.summary.trim() && !meta.slug.trim() && meta.tags.length === 0;
Expand Down Expand Up @@ -99,8 +120,12 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
const [busy, setBusy] = useState(false);
const [sentFile, setSentFile] = useState<string | null>(null);
const [siteTheme, setSiteTheme] = useState<'light' | 'dark'>('light');
const [openError, setOpenError] = useState<string | null>(null);
const [openUrl, setOpenUrl] = useState('');
const [openDialog, setOpenDialog] = useState(false);

const variantCss = useMemo(() => tableVariantCss(tableVariants), [tableVariants]);
const images = collectImages(editor.document as unknown as SBlock[]);

useEffect(() => {
const root = document.documentElement;
Expand Down Expand Up @@ -199,6 +224,34 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
setRestore(null);
};

const openExisting = async () => {
const input = openUrl.trim();
if (!input) return;
setOpenError(null);
setBusy(true);
try {
clearAssets();
const loaded = await fetchExisting(repoUrl, input);
await clearStoredAssets().catch(() => undefined);
setRestore(null);
setSentFile(null);
setIssues([]);
editor.replaceBlocks(editor.document, loaded.blocks as never);
setMeta({
...emptyMeta(),
...loaded.meta,
authors: Array.isArray(loaded.meta.authors) ? loaded.meta.authors : [],
});
setTableVariants(loaded.tableVariants ?? {});
setOpenUrl('');
setOpenDialog(false);
} catch (err) {
setOpenError(err instanceof Error ? err.message : 'That post could not be opened.');
} finally {
setBusy(false);
}
};

const download = async () => {
const blocks = editor.document as unknown as SBlock[];
const found = validate(meta, blocks);
Expand All @@ -214,6 +267,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
repoUrl,
contactEmail,
assets: allAssets(),
sourceJson: buildSource(meta, blocks, tableVariants),
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
Expand Down Expand Up @@ -268,10 +322,78 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:

return (
<div className="write-portal">
<div className="write-topbar">
<button
type="button"
className="write-open-link"
onClick={() => {
setOpenError(null);
setOpenDialog(true);
}}
>
Edit a published post ↗
</button>
</div>

{openDialog && (
<div
className="write-modal-backdrop"
onClick={() => !busy && setOpenDialog(false)}
role="presentation"
>
<div
className="write-modal"
role="dialog"
aria-modal="true"
aria-label="Edit a published post"
onClick={(e) => e.stopPropagation()}
>
<h3>Edit a published post</h3>
<p>Paste the post’s URL.</p>
<input
type="text"
className="write-open-url"
placeholder="https://mlsystems.dev/blog/…"
aria-label="Post URL"
autoFocus
value={openUrl}
disabled={busy}
onChange={(e) => setOpenUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void openExisting();
}
}}
/>
{openError && <p className="write-modal-error">{openError}</p>}
<div className="write-modal-actions">
<button
type="button"
className="write-ghost"
disabled={busy}
onClick={() => setOpenDialog(false)}
>
Cancel
</button>
<button
type="button"
className="write-download"
disabled={busy || !openUrl.trim()}
onClick={() => void openExisting()}
>
{busy ? 'Loading…' : 'Open post'}
</button>
</div>
</div>
</div>
)}

<MetaForm
authors={authors}
topics={topics}
meta={meta}
images={images}
onChange={(m) => {
setMeta(m);
autosave();
Expand Down
156 changes: 156 additions & 0 deletions src/write/editor/editor-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,162 @@
padding: 2px;
}

.write-topbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.write-open-link {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: none;
border: none;
padding: 2px 0;
cursor: pointer;
}
.write-open-link:hover {
color: var(--accent);
}

.write-modal-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--ink) 45%, transparent);
}
.write-modal {
width: 100%;
max-width: 460px;
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: 12px;
padding: 24px;
box-shadow: 0 20px 60px color-mix(in srgb, var(--ink) 30%, transparent);
}
.write-modal h3 {
margin: 0 0 6px;
font-size: 18px;
}
.write-modal p {
margin: 0 0 14px;
font-size: 14px;
color: var(--ink-2);
}
.write-modal .write-open-url {
width: 100%;
font-family: var(--font-mono);
font-size: 13px;
color: var(--ink);
background: var(--paper-2, var(--paper));
border: 1px solid var(--line-2);
border-radius: 8px;
padding: 9px 12px;
}
.write-modal .write-open-url:focus {
outline: none;
border-color: var(--accent);
}
.write-modal-error {
color: var(--accent);
font-size: 13px;
margin: 12px 0 0 !important;
}
.write-modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}

.write-cover {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 24px;
}
.write-cover-pick {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.write-cover-thumb {
padding: 0;
border: 1px solid var(--line-2);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
background: none;
line-height: 0;
}
.write-cover-thumb:hover {
border-color: var(--accent);
}
.write-cover-thumb img {
width: 72px;
height: 48px;
object-fit: cover;
display: block;
}
.write-cover-upload {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
border: 1px dashed var(--line-2);
border-radius: 8px;
padding: 12px 16px;
cursor: pointer;
}
.write-cover-upload:hover {
color: var(--accent);
border-color: var(--accent);
}
.write-cover-upload input {
display: none;
}
.write-cover-set {
display: flex;
justify-content: center;
}
.write-cover-frame {
position: relative;
display: inline-block;
line-height: 0;
}
.write-cover-frame img {
max-width: 220px;
max-height: 150px;
width: auto;
height: auto;
border-radius: 8px;
border: 1px solid var(--line-2);
display: block;
}
.write-cover-remove {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--paper);
background: var(--ink);
border: 1px solid var(--paper);
border-radius: 99px;
cursor: pointer;
}
.write-cover-remove:hover {
background: var(--accent);
}

.write-banner {
display: flex;
align-items: center;
Expand Down
Loading
Loading