Spaces:
Runtime error
Runtime error
Commit ·
2823b28
1
Parent(s): 528cb2d
launching
Browse files- README.md +1 -1
- backend/app/services/email.py +3 -2
- frontend/index.html +2 -2
- frontend/seo/sitemap.ts +84 -0
- frontend/src/components/PublicChrome.tsx +1 -1
- frontend/src/content/blogPosts.ts +2 -0
- frontend/src/content/missionPosts.ts +496 -0
- frontend/src/content/seoTypes.ts +10 -0
- frontend/src/pages/Blog.tsx +245 -66
- frontend/src/pages/Landing.tsx +13 -10
- frontend/src/pages/Sources.tsx +4 -4
- frontend/src/pages/Welcome.tsx +5 -5
- frontend/src/styles/blog.css +347 -0
- frontend/tsconfig.tsbuildinfo +1 -1
- frontend/vite.config.ts +5 -1
- renderer/public/demo-vo/durations.json +2 -2
- renderer/src/Root.tsx +1 -1
- renderer/src/compositions/LandingDemo.tsx +63 -20
README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Notestack
|
| 2 |
|
| 3 |
-
Your
|
| 4 |
overviews, videos and launch kits, all traced back to what the writer actually wrote.
|
| 5 |
|
| 6 |
- Design doc: [docs/DESIGN.md](docs/DESIGN.md)
|
|
|
|
| 1 |
# Notestack
|
| 2 |
|
| 3 |
+
Your knowledge, in orbit. Turn any blog, newsletter, site or folder of markdown into a grounded research notebook, audio
|
| 4 |
overviews, videos and launch kits, all traced back to what the writer actually wrote.
|
| 5 |
|
| 6 |
- Design doc: [docs/DESIGN.md](docs/DESIGN.md)
|
backend/app/services/email.py
CHANGED
|
@@ -140,8 +140,9 @@ class EmailService:
|
|
| 140 |
first = (name or "there").split(" ")[0]
|
| 141 |
url = f"{settings.frontend_url}/app"
|
| 142 |
body = (
|
| 143 |
-
f"Hi {html.escape(first)},<br><br>Your
|
| 144 |
-
"
|
|
|
|
| 145 |
+ _button("Open Mission Control", url)
|
| 146 |
)
|
| 147 |
return self._send(
|
|
|
|
| 140 |
first = (name or "there").split(" ")[0]
|
| 141 |
url = f"{settings.frontend_url}/app"
|
| 142 |
body = (
|
| 143 |
+
f"Hi {html.escape(first)},<br><br>Your knowledge is about to come into orbit. Connect any blog, newsletter "
|
| 144 |
+
"or site, or upload your markdown files, and Notestack will index your posts, learn your voice and get "
|
| 145 |
+
"your first audio overview ready."
|
| 146 |
+ _button("Open Mission Control", url)
|
| 147 |
)
|
| 148 |
return self._send(
|
frontend/index.html
CHANGED
|
@@ -4,12 +4,12 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#000000" />
|
| 7 |
-
<meta name="description" content="Notestack turns
|
| 8 |
<link rel="icon" href="/logo.svg" />
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 10 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 11 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
|
| 12 |
-
<title>Notestack: your
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#000000" />
|
| 7 |
+
<meta name="description" content="Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice." />
|
| 8 |
<link rel="icon" href="/logo.svg" />
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 10 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 11 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
|
| 12 |
+
<title>Notestack: your knowledge, in orbit</title>
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
frontend/seo/sitemap.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Plugin } from "vite";
|
| 2 |
+
import { blogPosts } from "../src/content/blogPosts";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live
|
| 6 |
+
* by the dev server), so publishing a post updates the sitemap automatically.
|
| 7 |
+
*
|
| 8 |
+
* SITE_URL sets the public origin, e.g. SITE_URL=https://notestack.ai (set it in the Pages build env).
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
const STATIC_PAGES: { path: string; priority: string; changefreq: string }[] = [
|
| 12 |
+
{ path: "/", priority: "1.0", changefreq: "weekly" },
|
| 13 |
+
{ path: "/pricing", priority: "0.8", changefreq: "monthly" },
|
| 14 |
+
{ path: "/blogs", priority: "0.9", changefreq: "weekly" },
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
const escapeXml = (s: string) =>
|
| 18 |
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
| 19 |
+
|
| 20 |
+
export function sitemapXml(origin: string): string {
|
| 21 |
+
const base = origin.replace(/\/+$/, "");
|
| 22 |
+
// Posts whose canonical lives elsewhere are left out, as the BlogPost type promises.
|
| 23 |
+
const posts = blogPosts
|
| 24 |
+
.filter((p) => !p.canonicalPath)
|
| 25 |
+
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
| 26 |
+
const newest = posts[0]?.publishedAt;
|
| 27 |
+
const urls = [
|
| 28 |
+
...STATIC_PAGES.map((p) => ({
|
| 29 |
+
loc: `${base}${p.path}`,
|
| 30 |
+
lastmod: p.path === "/blogs" ? newest : undefined,
|
| 31 |
+
changefreq: p.changefreq,
|
| 32 |
+
priority: p.priority,
|
| 33 |
+
})),
|
| 34 |
+
...posts.map((p) => ({
|
| 35 |
+
loc: `${base}/blogs/${p.slug}`,
|
| 36 |
+
lastmod: p.publishedAt,
|
| 37 |
+
changefreq: "monthly",
|
| 38 |
+
priority: "0.7",
|
| 39 |
+
})),
|
| 40 |
+
];
|
| 41 |
+
const body = urls
|
| 42 |
+
.map((u) =>
|
| 43 |
+
[
|
| 44 |
+
" <url>",
|
| 45 |
+
` <loc>${escapeXml(u.loc)}</loc>`,
|
| 46 |
+
u.lastmod ? ` <lastmod>${u.lastmod}</lastmod>` : "",
|
| 47 |
+
` <changefreq>${u.changefreq}</changefreq>`,
|
| 48 |
+
` <priority>${u.priority}</priority>`,
|
| 49 |
+
" </url>",
|
| 50 |
+
]
|
| 51 |
+
.filter(Boolean)
|
| 52 |
+
.join("\n"),
|
| 53 |
+
)
|
| 54 |
+
.join("\n");
|
| 55 |
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function robotsTxt(origin: string): string {
|
| 59 |
+
const base = origin.replace(/\/+$/, "");
|
| 60 |
+
return `User-agent: *\nAllow: /\nDisallow: /app\nDisallow: /auth\nDisallow: /welcome\n\nSitemap: ${base}/sitemap.xml\n`;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function sitemapPlugin(origin: string): Plugin {
|
| 64 |
+
return {
|
| 65 |
+
name: "notestack-sitemap",
|
| 66 |
+
generateBundle() {
|
| 67 |
+
this.emitFile({ type: "asset", fileName: "sitemap.xml", source: sitemapXml(origin) });
|
| 68 |
+
this.emitFile({ type: "asset", fileName: "robots.txt", source: robotsTxt(origin) });
|
| 69 |
+
},
|
| 70 |
+
configureServer(server) {
|
| 71 |
+
server.middlewares.use((req, res, next) => {
|
| 72 |
+
if (req.url === "/sitemap.xml") {
|
| 73 |
+
res.setHeader("Content-Type", "application/xml; charset=utf-8");
|
| 74 |
+
res.end(sitemapXml(origin));
|
| 75 |
+
} else if (req.url === "/robots.txt") {
|
| 76 |
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
| 77 |
+
res.end(robotsTxt(origin));
|
| 78 |
+
} else {
|
| 79 |
+
next();
|
| 80 |
+
}
|
| 81 |
+
});
|
| 82 |
+
},
|
| 83 |
+
};
|
| 84 |
+
}
|
frontend/src/components/PublicChrome.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export function PublicFooter() {
|
|
| 41 |
<div className="container footer-inner">
|
| 42 |
<div className="footer-brand">
|
| 43 |
<Logo />
|
| 44 |
-
<p className="muted">Your
|
| 45 |
</div>
|
| 46 |
<div className="footer-cols">
|
| 47 |
<div>
|
|
|
|
| 41 |
<div className="container footer-inner">
|
| 42 |
<div className="footer-brand">
|
| 43 |
<Logo />
|
| 44 |
+
<p className="muted">Your knowledge, in orbit.</p>
|
| 45 |
</div>
|
| 46 |
<div className="footer-cols">
|
| 47 |
<div>
|
frontend/src/content/blogPosts.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
|
|
| 1 |
import type { BlogPost } from "./seoTypes";
|
| 2 |
|
| 3 |
// Newest first. Cover images live in public/blog/blog-cover-<slug>.png.
|
| 4 |
// House rule: no em dashes anywhere in post copy (npm run check:copy enforces it).
|
| 5 |
|
| 6 |
export const blogPosts: BlogPost[] = [
|
|
|
|
| 7 |
{
|
| 8 |
slug: "turn-substack-archive-into-podcast",
|
| 9 |
title: "How to Turn Your Substack Archive Into a Podcast Without Recording",
|
|
|
|
| 1 |
+
import { missionPosts } from "./missionPosts";
|
| 2 |
import type { BlogPost } from "./seoTypes";
|
| 3 |
|
| 4 |
// Newest first. Cover images live in public/blog/blog-cover-<slug>.png.
|
| 5 |
// House rule: no em dashes anywhere in post copy (npm run check:copy enforces it).
|
| 6 |
|
| 7 |
export const blogPosts: BlogPost[] = [
|
| 8 |
+
...missionPosts,
|
| 9 |
{
|
| 10 |
slug: "turn-substack-archive-into-podcast",
|
| 11 |
title: "How to Turn Your Substack Archive Into a Podcast Without Recording",
|
frontend/src/content/missionPosts.ts
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { BlogPost } from "./seoTypes";
|
| 2 |
+
|
| 3 |
+
// SEO missions (keyword research: DataForSEO, US, September 2026).
|
| 4 |
+
// notebooklm alternatives 590/mo KD 0 alternative to notebooklm 170/mo
|
| 5 |
+
// best ai for research 2,400/mo KD 19 ai tools for research 720/mo KD 10
|
| 6 |
+
// ai tools for writers 880/mo KD 22 best ai for writing 4,400/mo KD 26
|
| 7 |
+
// notebooklm vs chatgpt 320/mo KD 0 grounded ai 480/mo KD 8
|
| 8 |
+
// ai second brain 390/mo KD 16 rising fast (110 to 880/mo over the year)
|
| 9 |
+
// Sister products are linked where they answer the reader's next question:
|
| 10 |
+
// blog2video.app (posts, URLs and PDFs to video), pdf2vid.com (PDF to narrated video),
|
| 11 |
+
// bloghub.app (free blog and newsletter directory).
|
| 12 |
+
// House rule: no em dashes.
|
| 13 |
+
|
| 14 |
+
export const missionPosts: BlogPost[] = [
|
| 15 |
+
{
|
| 16 |
+
slug: "notebooklm-alternatives",
|
| 17 |
+
title: "NotebookLM Alternatives in 2026: The Best Picks for Writers and Researchers",
|
| 18 |
+
description:
|
| 19 |
+
"An honest guide to NotebookLM alternatives: which tool to use for grounded research, open source privacy, audio, video and working with your own writing archive.",
|
| 20 |
+
category: "Research",
|
| 21 |
+
publishedAt: "2026-09-27",
|
| 22 |
+
readTime: "9 min read",
|
| 23 |
+
heroEyebrow: "NotebookLM alternatives",
|
| 24 |
+
heroTitle: "Looking past NotebookLM",
|
| 25 |
+
heroDescription:
|
| 26 |
+
"NotebookLM made source grounded AI mainstream. It is also a bundle, and most people only need part of it. Here is how to pick the part that matters to you.",
|
| 27 |
+
primaryKeyword: "notebooklm alternatives",
|
| 28 |
+
keywordVariant: "alternative to notebooklm",
|
| 29 |
+
relatedPaths: ["/blogs/notebooklm-vs-chatgpt", "/blogs/best-ai-for-research", "/blogs/ai-tools-for-writers"],
|
| 30 |
+
mission: {
|
| 31 |
+
question: "What is the best NotebookLM alternative for my kind of work?",
|
| 32 |
+
answer:
|
| 33 |
+
"It depends on which part of NotebookLM you rely on. For grounded chat over your own writing, use a tool that cites exact lines (Notestack for writers, Khoj if you want open source and self hosted). For academic papers, Elicit or Consensus. For turning documents into video, [PDF2Video](https://pdf2vid.com) or [Blog2Video](https://blog2video.app).",
|
| 34 |
+
},
|
| 35 |
+
sections: [
|
| 36 |
+
{
|
| 37 |
+
heading: "First, decide which part of NotebookLM you actually use",
|
| 38 |
+
paragraphs: [
|
| 39 |
+
"NotebookLM is several products in one: a place to upload sources, a chat that answers only from them, study guides, audio overviews and, more recently, video overviews. Most people who search for an alternative love one of those and are frustrated by another.",
|
| 40 |
+
"So before comparing tools, name the job. Are you researching other people's documents, or working with your own writing? Do you need citations you can click, or a quick summary? Do you want to listen, watch or publish the result?",
|
| 41 |
+
],
|
| 42 |
+
bullets: [
|
| 43 |
+
"**Grounded chat over sources**: answers that only come from what you uploaded",
|
| 44 |
+
"**Your own archive**: years of posts or notes you want to question and reuse",
|
| 45 |
+
"**Audio and video**: turning material into something people can listen to or watch",
|
| 46 |
+
"**Privacy and control**: running it locally or on your own servers",
|
| 47 |
+
],
|
| 48 |
+
callout: "The best alternative is the one that does your one job better, not the one that copies every feature.",
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
heading: "For grounded answers over your own writing: Notestack",
|
| 52 |
+
paragraphs: [
|
| 53 |
+
"If the sources you care about are your own posts, a general notebook makes you upload files by hand and loses track of what you wrote when. Notestack connects to your Substack, Ghost, Medium or any RSS feed, keeps each post as its own file, and answers with citations to the exact lines it read.",
|
| 54 |
+
"The difference shows up in the details. Answers cite line ranges, not whole documents, and anything the tool cannot support from your posts is dropped instead of guessed. The same archive feeds audio overviews, video and Launch Kits written in your voice.",
|
| 55 |
+
],
|
| 56 |
+
bullets: [
|
| 57 |
+
"Syncs a whole publication, including the back catalogue",
|
| 58 |
+
"Line level citations you can open and check",
|
| 59 |
+
"Topic map, voice profile and resurfacing built on the same archive",
|
| 60 |
+
],
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
heading: "For open source and self hosting: Khoj and friends",
|
| 64 |
+
paragraphs: [
|
| 65 |
+
"If your documents cannot leave your machine, look at open source options. Khoj is the most mature: it indexes your files and notes, answers with sources, and can run on your own hardware with a local model. Obsidian with an AI plugin is a lighter route if you already live in markdown.",
|
| 66 |
+
"The trade off is setup and maintenance. You choose the model, handle updates and accept that local models are weaker at long, careful reasoning than the best hosted ones.",
|
| 67 |
+
],
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
heading: "For academic research: Elicit, Consensus and Perplexity Spaces",
|
| 71 |
+
paragraphs: [
|
| 72 |
+
"NotebookLM only knows what you upload. Research tools built for papers go further by finding the papers too. Elicit pulls findings from studies into comparison tables, Consensus answers questions from peer reviewed literature, and Perplexity Spaces combines your files with live web search.",
|
| 73 |
+
"Use them to discover and screen sources, then bring the ones that matter into a grounded notebook where every claim stays tied to a page. Our guide to the [best AI for research](/blogs/best-ai-for-research) walks through that workflow step by step.",
|
| 74 |
+
],
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
heading: "For audio and video overviews",
|
| 78 |
+
paragraphs: [
|
| 79 |
+
"Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.",
|
| 80 |
+
"Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.",
|
| 81 |
+
],
|
| 82 |
+
callout: "Research in a notebook. Publish with a tool built for the format.",
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
heading: "How to switch without losing your work",
|
| 86 |
+
paragraphs: [
|
| 87 |
+
"Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.",
|
| 88 |
+
"If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.",
|
| 89 |
+
],
|
| 90 |
+
},
|
| 91 |
+
],
|
| 92 |
+
faq: [
|
| 93 |
+
{
|
| 94 |
+
question: "Is there a free alternative to NotebookLM?",
|
| 95 |
+
answer:
|
| 96 |
+
"Yes. Khoj is open source and free to self host, most research tools have free tiers, and Notestack has a free plan for trying grounded chat on your latest posts.",
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
question: "Is there an open source NotebookLM alternative?",
|
| 100 |
+
answer:
|
| 101 |
+
"Khoj is the most complete open source option. You can run it locally with your own model, which keeps documents on your machine.",
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
question: "What is the best NotebookLM alternative for video overviews?",
|
| 105 |
+
answer:
|
| 106 |
+
"Use a tool built for video. [PDF2Video](https://pdf2vid.com) turns PDFs into narrated videos and [Blog2Video](https://blog2video.app) turns blog posts, URLs and scripts into videos.",
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
question: "Which NotebookLM alternative is best for writers?",
|
| 110 |
+
answer:
|
| 111 |
+
"One that connects to your publication instead of relying on manual uploads, cites exact lines, and turns answers into audio, video and posts in your voice. That is what Notestack is built for.",
|
| 112 |
+
},
|
| 113 |
+
],
|
| 114 |
+
distributionPlan: [
|
| 115 |
+
{ channel: "x", angle: "Thread: pick a NotebookLM alternative by the one job you need done" },
|
| 116 |
+
{ channel: "linkedin", angle: "Why grounded AI for your own archive beats uploading PDFs one by one" },
|
| 117 |
+
{ channel: "substack_notes", angle: "The NotebookLM feature writers miss most, and where to find it" },
|
| 118 |
+
],
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
slug: "best-ai-for-research",
|
| 122 |
+
title: "Best AI for Research in 2026: A Workflow, Not a Single Tool",
|
| 123 |
+
description:
|
| 124 |
+
"The best AI for research depends on the stage: finding sources, reading them, synthesizing and sharing. Here are the AI tools for research that fit each stage, and how to keep every claim traceable.",
|
| 125 |
+
category: "Research",
|
| 126 |
+
publishedAt: "2026-09-26",
|
| 127 |
+
readTime: "8 min read",
|
| 128 |
+
heroEyebrow: "AI for research",
|
| 129 |
+
heroTitle: "Research is a trip in stages",
|
| 130 |
+
heroDescription:
|
| 131 |
+
"No single AI is best at finding, reading, thinking and explaining. Chain the right tools and keep a citation trail from the first search to the final draft.",
|
| 132 |
+
primaryKeyword: "best ai for research",
|
| 133 |
+
keywordVariant: "ai tools for research",
|
| 134 |
+
relatedPaths: ["/blogs/notebooklm-alternatives", "/blogs/notebooklm-vs-chatgpt", "/blogs/ai-tools-for-writers"],
|
| 135 |
+
mission: {
|
| 136 |
+
question: "Which AI is actually best for research?",
|
| 137 |
+
answer:
|
| 138 |
+
"No single one. Use a discovery tool (Elicit, Consensus or Perplexity) to find sources, a grounded notebook to read and synthesize them with line level citations, and a publishing tool such as [PDF2Video](https://pdf2vid.com) to share findings. The best setup is the one where every claim can be traced back to a source.",
|
| 139 |
+
},
|
| 140 |
+
sections: [
|
| 141 |
+
{
|
| 142 |
+
heading: "Why one tool is never enough",
|
| 143 |
+
paragraphs: [
|
| 144 |
+
"General chatbots are good at sounding certain, which is the opposite of what research needs. The failure is rarely a wrong fact on its own. It is a confident sentence with no trail back to where it came from.",
|
| 145 |
+
"Good research AI is less about intelligence and more about provenance. At every stage, ask one question: can I click through to the exact passage behind this sentence?",
|
| 146 |
+
],
|
| 147 |
+
callout: "If you cannot trace it, you cannot trust it.",
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
heading: "Stage one: find the sources",
|
| 151 |
+
paragraphs: [
|
| 152 |
+
"Discovery tools search far beyond what you already have. Consensus answers questions from peer reviewed papers, Elicit extracts findings from studies into tables you can compare, and Perplexity searches the live web with links for every claim.",
|
| 153 |
+
"Treat this stage as a net, not a verdict. Save the promising sources, skim the abstracts, and move the ones that matter into a place where you can read them properly.",
|
| 154 |
+
],
|
| 155 |
+
bullets: ["Consensus for evidence from papers", "Elicit for comparing many studies side by side", "Perplexity for current events and the open web"],
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
heading: "Stage two: read deeply with a grounded notebook",
|
| 159 |
+
paragraphs: [
|
| 160 |
+
"Once you have sources, you want an AI that answers only from them. This is where grounded notebooks shine: NotebookLM for uploaded documents, and Notestack when the sources are your own writing, synced straight from your publication.",
|
| 161 |
+
"The best of these do more than summarize. They search with several phrasings, read around each hit, and cite the line range they used, so you can open the passage and check the context yourself.",
|
| 162 |
+
],
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
heading: "Stage three: synthesize without losing the trail",
|
| 166 |
+
paragraphs: [
|
| 167 |
+
"Synthesis is where most AI research breaks. A summary of ten papers quietly blends them, and the nuance of which source said what disappears.",
|
| 168 |
+
"Keep synthesis inside the grounded notebook, ask for claims with citations rather than prose, and write the final argument yourself. If you publish on Substack, a notebook built from your own posts also shows how your thinking has changed over time, which is research most writers never get to do.",
|
| 169 |
+
],
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
heading: "Stage four: share what you found",
|
| 173 |
+
paragraphs: [
|
| 174 |
+
"Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.",
|
| 175 |
+
"For findings you have written up as a post, [Blog2Video](https://blog2video.app) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.",
|
| 176 |
+
],
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
heading: "A simple stack to start with",
|
| 180 |
+
paragraphs: ["If you want one setup to try this week, keep it small and make each tool earn its place."],
|
| 181 |
+
bullets: [
|
| 182 |
+
"Discover: Consensus or Elicit, plus Perplexity for the web",
|
| 183 |
+
"Read and synthesize: a grounded notebook with line citations",
|
| 184 |
+
"Write: your own words, with the notebook open beside you",
|
| 185 |
+
"Share: a narrated video with [PDF2Video](https://pdf2vid.com) or an audio overview",
|
| 186 |
+
],
|
| 187 |
+
},
|
| 188 |
+
],
|
| 189 |
+
faq: [
|
| 190 |
+
{
|
| 191 |
+
question: "Is ChatGPT good for research?",
|
| 192 |
+
answer:
|
| 193 |
+
"It is good for brainstorming and explaining concepts, but it can state things confidently without a source. For research, pair it with tools that cite, or read our [NotebookLM vs ChatGPT](/blogs/notebooklm-vs-chatgpt) comparison.",
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
question: "What are the best free AI tools for research?",
|
| 197 |
+
answer:
|
| 198 |
+
"Consensus, Elicit and Perplexity all have free tiers for discovery, NotebookLM is free for reading uploaded sources, and open source tools like Khoj are free to self host.",
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
question: "Which AI is most reliable for research?",
|
| 202 |
+
answer:
|
| 203 |
+
"The most reliable is the one that shows its sources at the passage level and refuses to answer when the sources do not cover the question. Reliability comes from grounding, not from model size.",
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
question: "How do I turn research papers into videos?",
|
| 207 |
+
answer:
|
| 208 |
+
"Upload the PDF to [PDF2Video](https://pdf2vid.com) and it produces a narrated video. For findings published as a blog post, use [Blog2Video](https://blog2video.app).",
|
| 209 |
+
},
|
| 210 |
+
],
|
| 211 |
+
distributionPlan: [
|
| 212 |
+
{ channel: "linkedin", angle: "The four stage research stack, and why provenance beats intelligence" },
|
| 213 |
+
{ channel: "x", angle: "Thread: best AI for research depends on the stage you are in" },
|
| 214 |
+
{ channel: "youtube", angle: "Narrated walkthrough made with PDF2Video from the workflow checklist" },
|
| 215 |
+
],
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
slug: "ai-tools-for-writers",
|
| 219 |
+
title: "AI Tools for Writers Who Care About Depth, Not Word Count",
|
| 220 |
+
description:
|
| 221 |
+
"The best AI for writing is not the one that writes the most. Here are AI tools for writers that help with research, memory, voice and distribution, while you keep doing the deep work.",
|
| 222 |
+
category: "Writing",
|
| 223 |
+
publishedAt: "2026-09-25",
|
| 224 |
+
readTime: "8 min read",
|
| 225 |
+
heroEyebrow: "AI for deep writing",
|
| 226 |
+
heroTitle: "Use AI for everything around the writing",
|
| 227 |
+
heroDescription:
|
| 228 |
+
"Deep writing is slow on purpose. The right AI speeds up the research, the remembering and the distribution, so the slow part gets more of your time.",
|
| 229 |
+
primaryKeyword: "ai tools for writers",
|
| 230 |
+
keywordVariant: "best ai for writing",
|
| 231 |
+
relatedPaths: ["/blogs/ai-second-brain-for-writers", "/blogs/best-ai-for-research", "/blogs/notebooklm-alternatives"],
|
| 232 |
+
mission: {
|
| 233 |
+
question: "How can AI help me write deeper instead of just faster?",
|
| 234 |
+
answer:
|
| 235 |
+
"Keep the drafting and let AI handle the work around it: research with citations, remembering what you already wrote, checking drafts against your voice, and turning finished posts into audio, video and social posts. The best AI tools for writers protect your thinking time instead of replacing it.",
|
| 236 |
+
},
|
| 237 |
+
sections: [
|
| 238 |
+
{
|
| 239 |
+
heading: "Why most AI writing tools make writing shallower",
|
| 240 |
+
paragraphs: [
|
| 241 |
+
"Most AI writing assistants are built to produce more words. That is useful for product descriptions and useless for an essay whose value is the thinking behind it.",
|
| 242 |
+
"Readers can tell. Generated prose is smooth, general and forgettable, because it averages the internet instead of arguing a point only you would make.",
|
| 243 |
+
],
|
| 244 |
+
callout: "Depth comes from what you know and what you have already said. That is where AI should help.",
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
heading: "Research you can cite",
|
| 248 |
+
paragraphs: [
|
| 249 |
+
"The first job for AI in deep writing is research that holds up. Use tools that answer from sources and show the passage, not ones that paraphrase the web from memory.",
|
| 250 |
+
"Our guide to the [best AI for research](/blogs/best-ai-for-research) covers discovery tools and grounded notebooks. The rule for writers is simple: never publish a fact you cannot click through to.",
|
| 251 |
+
],
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
heading: "A memory of everything you have written",
|
| 255 |
+
paragraphs: [
|
| 256 |
+
"Writers with a long archive repeat themselves, contradict themselves and forget their best lines. A grounded notebook over your own posts fixes that. Ask what you have already argued about a topic and get the answer with citations to your past posts.",
|
| 257 |
+
"Notestack builds this from your publication automatically, then maps the topics you keep returning to and the ones that went dormant. It is the research assistant that has read everything you ever published.",
|
| 258 |
+
],
|
| 259 |
+
bullets: [
|
| 260 |
+
"Find what you already said before you say it again",
|
| 261 |
+
"See how your view changed over the years",
|
| 262 |
+
"Pull exact quotes with the line they came from",
|
| 263 |
+
],
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
heading: "A second reader for voice and structure",
|
| 267 |
+
paragraphs: [
|
| 268 |
+
"AI is a decent editor when it knows your voice. A voice profile built from your best posts lets it flag sentences that sound unlike you, suggest cuts, and point out where an argument jumps a step.",
|
| 269 |
+
"Keep it in the editor's chair. Ask for questions and critiques, not rewrites, and your draft stays yours.",
|
| 270 |
+
],
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
heading: "Distribution without the busywork",
|
| 274 |
+
paragraphs: [
|
| 275 |
+
"Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.",
|
| 276 |
+
"Video extends the reach further: [Blog2Video](https://blog2video.app) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app), a free directory of blogs and newsletters.",
|
| 277 |
+
],
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
heading: "A deep writing stack",
|
| 281 |
+
paragraphs: ["Here is a setup that protects the slow part of writing."],
|
| 282 |
+
bullets: [
|
| 283 |
+
"Research: a discovery tool plus a grounded notebook with citations",
|
| 284 |
+
"Memory: your own archive, searchable and cited",
|
| 285 |
+
"Editing: a voice profile that critiques instead of rewrites",
|
| 286 |
+
"Distribution: Launch Kits, [Blog2Video](https://blog2video.app) and a listing on [BlogHub](https://bloghub.app)",
|
| 287 |
+
],
|
| 288 |
+
},
|
| 289 |
+
],
|
| 290 |
+
faq: [
|
| 291 |
+
{
|
| 292 |
+
question: "What is the best AI for writing long form essays?",
|
| 293 |
+
answer:
|
| 294 |
+
"For long form work, the most useful AI is a grounded research and memory tool rather than a text generator. Draft yourself, and use AI to research, recall your past writing and edit against your voice.",
|
| 295 |
+
},
|
| 296 |
+
{
|
| 297 |
+
question: "Will using AI make my writing sound generic?",
|
| 298 |
+
answer:
|
| 299 |
+
"Only if it writes for you. Used for research, recall and critique, AI makes writing more specific, because you spend more time on the ideas only you have.",
|
| 300 |
+
},
|
| 301 |
+
{
|
| 302 |
+
question: "How do I grow readers for a newsletter?",
|
| 303 |
+
answer:
|
| 304 |
+
"Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app) so readers browsing for new blogs and newsletters can find it.",
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
question: "Can AI turn my blog posts into videos?",
|
| 308 |
+
answer: "Yes. [Blog2Video](https://blog2video.app) turns blog posts, URLs and scripts into videos without an editor.",
|
| 309 |
+
},
|
| 310 |
+
],
|
| 311 |
+
distributionPlan: [
|
| 312 |
+
{ channel: "substack_notes", angle: "Use AI for everything around the writing, never for the writing itself" },
|
| 313 |
+
{ channel: "linkedin", angle: "A deep writing stack: research, memory, voice, distribution" },
|
| 314 |
+
{ channel: "x", angle: "Thread: why most AI writing tools make writing shallower" },
|
| 315 |
+
],
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
slug: "notebooklm-vs-chatgpt",
|
| 319 |
+
title: "NotebookLM vs ChatGPT: Which Is Better for Research and Writing?",
|
| 320 |
+
description:
|
| 321 |
+
"NotebookLM vs ChatGPT comes down to grounding. One answers only from your sources, the other from everything it learned. Here is when to use each, and what grounded AI really means.",
|
| 322 |
+
category: "Research",
|
| 323 |
+
publishedAt: "2026-09-24",
|
| 324 |
+
readTime: "7 min read",
|
| 325 |
+
heroEyebrow: "Grounded AI",
|
| 326 |
+
heroTitle: "Two ways an AI can know things",
|
| 327 |
+
heroDescription:
|
| 328 |
+
"ChatGPT knows a little about everything. NotebookLM knows only what you give it. For research and writing, that difference decides whether you can trust the answer.",
|
| 329 |
+
primaryKeyword: "notebooklm vs chatgpt",
|
| 330 |
+
keywordVariant: "grounded ai",
|
| 331 |
+
relatedPaths: ["/blogs/notebooklm-alternatives", "/blogs/best-ai-for-research", "/blogs/grounded-ai-for-writers"],
|
| 332 |
+
mission: {
|
| 333 |
+
question: "Should I use NotebookLM or ChatGPT for research?",
|
| 334 |
+
answer:
|
| 335 |
+
"Use ChatGPT to brainstorm, explain and draft ideas, and a grounded tool like NotebookLM (or Notestack for your own writing) when accuracy matters. Grounded AI answers only from your sources and cites them, so you can check every claim.",
|
| 336 |
+
},
|
| 337 |
+
sections: [
|
| 338 |
+
{
|
| 339 |
+
heading: "The real difference is grounding",
|
| 340 |
+
paragraphs: [
|
| 341 |
+
"ChatGPT answers from patterns in its training data, plus web search when enabled. NotebookLM answers from the sources you upload and cites them. Both use large language models; they differ in what they are allowed to rely on.",
|
| 342 |
+
"Grounded AI means the model can only use a defined set of sources and must point to where each claim came from. That constraint is what makes it useful for research.",
|
| 343 |
+
],
|
| 344 |
+
callout: "An ungrounded answer asks for your trust. A grounded one shows its work.",
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
heading: "Where ChatGPT wins",
|
| 348 |
+
paragraphs: [
|
| 349 |
+
"ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.",
|
| 350 |
+
"Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source.",
|
| 351 |
+
],
|
| 352 |
+
bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"],
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
heading: "Where NotebookLM wins",
|
| 356 |
+
paragraphs: [
|
| 357 |
+
"Once you have sources, NotebookLM is safer. Its answers stay inside your documents, it cites them, and features like audio overviews help you absorb long material.",
|
| 358 |
+
"Its limits are about workflow. You upload documents one at a time, citations point to passages but not always the exact lines, and there is no built in way to publish what you learn.",
|
| 359 |
+
],
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
heading: "What writers need that neither does",
|
| 363 |
+
paragraphs: [
|
| 364 |
+
"If your main source is your own archive, you need a notebook that syncs your publication, keeps each post intact and cites the exact lines. That is the gap Notestack fills, and our list of [NotebookLM alternatives](/blogs/notebooklm-alternatives) covers the other options.",
|
| 365 |
+
"Writers also need to ship. Turn a PDF or report into a narrated explainer with [PDF2Video](https://pdf2vid.com), or turn a post into video with [Blog2Video](https://blog2video.app), so the research ends up in front of people.",
|
| 366 |
+
],
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
heading: "How to use them together",
|
| 370 |
+
paragraphs: ["The strongest workflow uses both, in order."],
|
| 371 |
+
bullets: [
|
| 372 |
+
"Explore with ChatGPT until you know what to look for",
|
| 373 |
+
"Collect sources and move them into a grounded notebook",
|
| 374 |
+
"Ask the notebook for claims with citations",
|
| 375 |
+
"Write your argument yourself, checking each claim against its source",
|
| 376 |
+
],
|
| 377 |
+
},
|
| 378 |
+
],
|
| 379 |
+
faq: [
|
| 380 |
+
{
|
| 381 |
+
question: "Is NotebookLM better than ChatGPT?",
|
| 382 |
+
answer:
|
| 383 |
+
"For questions about your own documents, yes, because it answers only from them and cites them. For open ended thinking and explanation, ChatGPT is more flexible.",
|
| 384 |
+
},
|
| 385 |
+
{
|
| 386 |
+
question: "What is grounded AI?",
|
| 387 |
+
answer:
|
| 388 |
+
"Grounded AI answers only from a defined set of sources and points to where each claim came from, so you can verify it instead of taking it on trust.",
|
| 389 |
+
},
|
| 390 |
+
{
|
| 391 |
+
question: "Does ChatGPT cite its sources?",
|
| 392 |
+
answer:
|
| 393 |
+
"With web search on, it links to pages. Without it, it answers from training data and cannot point to a specific source for each claim.",
|
| 394 |
+
},
|
| 395 |
+
{
|
| 396 |
+
question: "What is the best alternative to both for writers?",
|
| 397 |
+
answer:
|
| 398 |
+
"A grounded notebook built from your own publication, like Notestack, which cites exact lines and turns answers into audio, video and posts.",
|
| 399 |
+
},
|
| 400 |
+
],
|
| 401 |
+
distributionPlan: [
|
| 402 |
+
{ channel: "x", angle: "NotebookLM vs ChatGPT in one line: grounding" },
|
| 403 |
+
{ channel: "linkedin", angle: "When to use a thinking partner vs a grounded notebook" },
|
| 404 |
+
],
|
| 405 |
+
},
|
| 406 |
+
{
|
| 407 |
+
slug: "ai-second-brain-for-writers",
|
| 408 |
+
title: "An AI Second Brain for Writers: Turn Your Archive Into a Thinking Partner",
|
| 409 |
+
description:
|
| 410 |
+
"An AI second brain is only as good as what goes in and how it answers. For writers, the best second brain is your own archive, searchable, cited and ready to reuse.",
|
| 411 |
+
category: "Writing",
|
| 412 |
+
publishedAt: "2026-09-23",
|
| 413 |
+
readTime: "7 min read",
|
| 414 |
+
heroEyebrow: "AI second brain",
|
| 415 |
+
heroTitle: "You already built a second brain",
|
| 416 |
+
heroDescription:
|
| 417 |
+
"Every post you published is a note you took in public. An AI notebook over that archive becomes the second brain most writers spend years trying to build.",
|
| 418 |
+
primaryKeyword: "ai second brain",
|
| 419 |
+
keywordVariant: "ai notebook",
|
| 420 |
+
relatedPaths: ["/blogs/ai-tools-for-writers", "/blogs/notebooklm-alternatives", "/blogs/grounded-ai-for-writers"],
|
| 421 |
+
mission: {
|
| 422 |
+
question: "What is the best way to build an AI second brain as a writer?",
|
| 423 |
+
answer:
|
| 424 |
+
"Start with what you have already published. Connect your archive to an AI notebook that answers with citations, maps your recurring topics and resurfaces old posts. Your writing becomes a second brain you can question, instead of a folder of notes you never reopen.",
|
| 425 |
+
},
|
| 426 |
+
sections: [
|
| 427 |
+
{
|
| 428 |
+
heading: "Why most second brains end up abandoned",
|
| 429 |
+
paragraphs: [
|
| 430 |
+
"The classic second brain asks you to capture everything, tag it and review it. Most systems die at the review step: notes go in and never come back out.",
|
| 431 |
+
"AI changes the retrieval side. You no longer need perfect tags if you can ask a question and get the relevant notes back, with the exact passages cited.",
|
| 432 |
+
],
|
| 433 |
+
callout: "A second brain is only useful if it talks back.",
|
| 434 |
+
},
|
| 435 |
+
{
|
| 436 |
+
heading: "Your archive is the best notes you ever took",
|
| 437 |
+
paragraphs: [
|
| 438 |
+
"Private notes are fragments. Published posts are finished thoughts: argued, edited and dated. For a writer, that archive is the most valuable dataset you own.",
|
| 439 |
+
"Connecting it to an AI notebook lets you ask what you think about something and get an answer drawn from years of your own writing, with links to the lines you wrote.",
|
| 440 |
+
],
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
heading: "What a good AI second brain does",
|
| 444 |
+
paragraphs: ["Look for these capabilities, whatever tool you choose."],
|
| 445 |
+
bullets: [
|
| 446 |
+
"**Cites exact passages** so you can check the context",
|
| 447 |
+
"**Maps topics** so you see the themes you return to",
|
| 448 |
+
"**Surfaces dormant ideas** worth revisiting",
|
| 449 |
+
"**Remembers the conversation** so follow up questions work",
|
| 450 |
+
"**Stays yours**: no training on your content without opt in",
|
| 451 |
+
],
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
heading: "From remembering to publishing",
|
| 455 |
+
paragraphs: [
|
| 456 |
+
"The payoff comes when the second brain feeds new work. Resurface an evergreen post, turn it into a Launch Kit, or turn it into video with [Blog2Video](https://blog2video.app). Old ideas find new readers without you starting from a blank page.",
|
| 457 |
+
"Discovery matters too. Listing your publication on [BlogHub](https://bloghub.app) puts the archive you are mining in front of readers who are looking for new blogs and newsletters.",
|
| 458 |
+
],
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
heading: "Setting one up in ten minutes",
|
| 462 |
+
paragraphs: [
|
| 463 |
+
"Paste your Substack or blog URL into Notestack and let it index your posts. Ask it what you have written about your favourite topic, open the topic map, and check which ideas you have not touched in a year.",
|
| 464 |
+
"If you keep PDFs, research papers or slides alongside your writing, [PDF2Video](https://pdf2vid.com) can turn the best of them into narrated videos, so your second brain produces as well as remembers.",
|
| 465 |
+
],
|
| 466 |
+
},
|
| 467 |
+
],
|
| 468 |
+
faq: [
|
| 469 |
+
{
|
| 470 |
+
question: "What is an AI second brain?",
|
| 471 |
+
answer:
|
| 472 |
+
"A personal knowledge base that you can question in plain language, with an AI that finds and cites the relevant notes instead of you searching and tagging by hand.",
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
question: "Is Notion or Obsidian a good AI second brain?",
|
| 476 |
+
answer:
|
| 477 |
+
"Both work for private notes, especially with AI plugins. For writers, a notebook built from your published archive is often more useful, because those posts are finished thoughts.",
|
| 478 |
+
},
|
| 479 |
+
{
|
| 480 |
+
question: "What is the difference between an AI notebook and a chatbot?",
|
| 481 |
+
answer:
|
| 482 |
+
"A chatbot answers from general knowledge. An AI notebook answers from your own sources and cites them, which is what makes it a second brain rather than a search engine.",
|
| 483 |
+
},
|
| 484 |
+
{
|
| 485 |
+
question: "Can I turn my notes into videos?",
|
| 486 |
+
answer:
|
| 487 |
+
"Yes. Use [Blog2Video](https://blog2video.app) for posts and URLs, or [PDF2Video](https://pdf2vid.com) for PDFs and documents.",
|
| 488 |
+
},
|
| 489 |
+
],
|
| 490 |
+
distributionPlan: [
|
| 491 |
+
{ channel: "substack_notes", angle: "Your archive is the second brain you already built" },
|
| 492 |
+
{ channel: "x", angle: "Thread: why second brains die at the review step, and how AI fixes it" },
|
| 493 |
+
{ channel: "linkedin", angle: "Published posts are finished thoughts: the best dataset a writer owns" },
|
| 494 |
+
],
|
| 495 |
+
},
|
| 496 |
+
];
|
frontend/src/content/seoTypes.ts
CHANGED
|
@@ -14,6 +14,13 @@ export type DistributionAsset = {
|
|
| 14 |
angle: string;
|
| 15 |
};
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
export type BlogPost = {
|
| 18 |
slug: string;
|
| 19 |
title: string;
|
|
@@ -31,6 +38,9 @@ export type BlogPost = {
|
|
| 31 |
primaryKeyword: string;
|
| 32 |
keywordVariant: string;
|
| 33 |
relatedPaths: string[];
|
|
|
|
|
|
|
|
|
|
| 34 |
sections: BlogSection[];
|
| 35 |
faq: FaqItem[];
|
| 36 |
distributionPlan: DistributionAsset[];
|
|
|
|
| 14 |
angle: string;
|
| 15 |
};
|
| 16 |
|
| 17 |
+
/** The reader's journey: the question they searched for and the answer they leave with. */
|
| 18 |
+
export type Mission = {
|
| 19 |
+
question: string;
|
| 20 |
+
/** Two or three sentences. Shown up front (featured snippet) and again at the destination. */
|
| 21 |
+
answer: string;
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
export type BlogPost = {
|
| 25 |
slug: string;
|
| 26 |
title: string;
|
|
|
|
| 38 |
primaryKeyword: string;
|
| 39 |
keywordVariant: string;
|
| 40 |
relatedPaths: string[];
|
| 41 |
+
/** Optional; derived from the title and description when absent. */
|
| 42 |
+
mission?: Mission;
|
| 43 |
+
/** Paragraphs, bullets, callouts and FAQ answers support inline [text](url) links and **bold**. */
|
| 44 |
sections: BlogSection[];
|
| 45 |
faq: FaqItem[];
|
| 46 |
distributionPlan: DistributionAsset[];
|
frontend/src/pages/Blog.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
-
import { useEffect } from "react";
|
| 2 |
import { Link, useParams } from "react-router-dom";
|
| 3 |
import SkyCanvas from "../components/SkyCanvas";
|
| 4 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 5 |
import { blogPosts, getPost } from "../content/blogPosts";
|
|
|
|
|
|
|
| 6 |
|
| 7 |
function useMeta(title: string, description: string, canonical?: string) {
|
| 8 |
useEffect(() => {
|
|
@@ -31,40 +33,136 @@ function useMeta(title: string, description: string, canonical?: string) {
|
|
| 31 |
const fmtDate = (d: string) =>
|
| 32 |
new Date(`${d}T00:00:00Z`).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" });
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
export function Blog() {
|
| 35 |
-
useMeta("Blog | Notestack", "Field notes on turning your
|
|
|
|
| 36 |
return (
|
| 37 |
<div className="page">
|
| 38 |
-
<SkyCanvas intensity={0.
|
| 39 |
<PublicNav />
|
| 40 |
<main className="container blog-index">
|
| 41 |
-
<p className="eyebrow">
|
| 42 |
-
<h1 className="section-title">
|
| 43 |
<p className="muted blog-lede">
|
| 44 |
-
|
|
|
|
| 45 |
</p>
|
| 46 |
-
<
|
| 47 |
-
{
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</main>
|
| 59 |
<PublicFooter />
|
| 60 |
</div>
|
| 61 |
);
|
| 62 |
}
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
export function BlogPostPage() {
|
| 65 |
const { slug = "" } = useParams();
|
| 66 |
const post = getPost(slug);
|
| 67 |
useMeta(post ? `${post.title} | Notestack` : "Not found | Notestack", post?.description ?? "", post?.canonicalPath);
|
|
|
|
| 68 |
|
| 69 |
if (!post) {
|
| 70 |
return (
|
|
@@ -75,13 +173,15 @@ export function BlogPostPage() {
|
|
| 75 |
<h1>Lost in space</h1>
|
| 76 |
<p className="muted">That post drifted out of range.</p>
|
| 77 |
<Link to="/blogs" className="btn">
|
| 78 |
-
Back to the
|
| 79 |
</Link>
|
| 80 |
</main>
|
| 81 |
</div>
|
| 82 |
);
|
| 83 |
}
|
| 84 |
|
|
|
|
|
|
|
| 85 |
const related = post.relatedPaths
|
| 86 |
.map((path) => blogPosts.find((p) => `/blogs/${p.slug}` === path))
|
| 87 |
.filter((p): p is NonNullable<typeof p> => Boolean(p));
|
|
@@ -92,6 +192,7 @@ export function BlogPostPage() {
|
|
| 92 |
headline: post.title,
|
| 93 |
description: post.description,
|
| 94 |
datePublished: post.publishedAt,
|
|
|
|
| 95 |
author: { "@type": "Organization", name: "Notestack" },
|
| 96 |
};
|
| 97 |
const faqLd = post.faq.length
|
|
@@ -101,63 +202,138 @@ export function BlogPostPage() {
|
|
| 101 |
mainEntity: post.faq.map((f) => ({
|
| 102 |
"@type": "Question",
|
| 103 |
name: f.question,
|
| 104 |
-
acceptedAnswer: { "@type": "Answer", text: f.answer },
|
| 105 |
})),
|
| 106 |
}
|
| 107 |
: null;
|
| 108 |
|
|
|
|
|
|
|
| 109 |
return (
|
| 110 |
<div className="page">
|
| 111 |
-
<SkyCanvas intensity={0.
|
| 112 |
<PublicNav />
|
| 113 |
-
<main className="container post">
|
| 114 |
<script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
|
| 115 |
{faqLd && <script type="application/ld+json">{JSON.stringify(faqLd)}</script>}
|
| 116 |
<Link to="/blogs" className="mono muted post-back">
|
| 117 |
-
|
| 118 |
</Link>
|
| 119 |
-
|
| 120 |
-
<
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
{post.
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
<
|
| 152 |
-
</
|
| 153 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
</section>
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
<aside className="card post-cta">
|
| 159 |
-
<h3>Put your
|
| 160 |
-
<p className="muted">
|
| 161 |
<Link to="/auth?mode=signup" className="btn btn-primary">
|
| 162 |
Start free
|
| 163 |
</Link>
|
|
@@ -165,13 +341,16 @@ export function BlogPostPage() {
|
|
| 165 |
|
| 166 |
{related.length > 0 && (
|
| 167 |
<section className="post-related">
|
| 168 |
-
<p className="eyebrow">
|
| 169 |
<div className="blog-grid">
|
| 170 |
{related.map((p) => (
|
| 171 |
-
<
|
| 172 |
-
<
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
| 175 |
))}
|
| 176 |
</div>
|
| 177 |
</section>
|
|
|
|
| 1 |
+
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
| 2 |
import { Link, useParams } from "react-router-dom";
|
| 3 |
import SkyCanvas from "../components/SkyCanvas";
|
| 4 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 5 |
import { blogPosts, getPost } from "../content/blogPosts";
|
| 6 |
+
import type { BlogPost, Mission } from "../content/seoTypes";
|
| 7 |
+
import "../styles/blog.css";
|
| 8 |
|
| 9 |
function useMeta(title: string, description: string, canonical?: string) {
|
| 10 |
useEffect(() => {
|
|
|
|
| 33 |
const fmtDate = (d: string) =>
|
| 34 |
new Date(`${d}T00:00:00Z`).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" });
|
| 35 |
|
| 36 |
+
/** Posts are numbered as missions in publishing order: the oldest post is Mission 01. */
|
| 37 |
+
const missionNumber = (post: BlogPost) => {
|
| 38 |
+
const ordered = [...blogPosts].sort((a, b) => a.publishedAt.localeCompare(b.publishedAt));
|
| 39 |
+
return String(ordered.findIndex((p) => p.slug === post.slug) + 1).padStart(2, "0");
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const missionOf = (post: BlogPost): Mission =>
|
| 43 |
+
post.mission ?? { question: post.title.endsWith("?") ? post.title : `${post.heroTitle}?`, answer: post.description };
|
| 44 |
+
|
| 45 |
+
const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g;
|
| 46 |
+
|
| 47 |
+
/** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */
|
| 48 |
+
function Inline({ text }: { text: string }) {
|
| 49 |
+
const out: ReactNode[] = [];
|
| 50 |
+
let last = 0;
|
| 51 |
+
let i = 0;
|
| 52 |
+
for (const m of text.matchAll(INLINE)) {
|
| 53 |
+
const at = m.index ?? 0;
|
| 54 |
+
if (at > last) out.push(text.slice(last, at));
|
| 55 |
+
const [whole, link, bold] = m;
|
| 56 |
+
if (link) {
|
| 57 |
+
const label = link.slice(1, link.indexOf("]("));
|
| 58 |
+
const href = link.slice(link.indexOf("](") + 2, -1);
|
| 59 |
+
out.push(
|
| 60 |
+
href.startsWith("/") ? (
|
| 61 |
+
<Link key={i++} to={href}>
|
| 62 |
+
{label}
|
| 63 |
+
</Link>
|
| 64 |
+
) : (
|
| 65 |
+
<a key={i++} href={href} target="_blank" rel="noopener">
|
| 66 |
+
{label}
|
| 67 |
+
</a>
|
| 68 |
+
),
|
| 69 |
+
);
|
| 70 |
+
} else if (bold) {
|
| 71 |
+
out.push(<strong key={i++}>{bold.slice(2, -2)}</strong>);
|
| 72 |
+
}
|
| 73 |
+
last = at + whole.length;
|
| 74 |
+
}
|
| 75 |
+
if (last < text.length) out.push(text.slice(last));
|
| 76 |
+
return <>{out}</>;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const plain = (text: string) => text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1");
|
| 80 |
+
|
| 81 |
export function Blog() {
|
| 82 |
+
useMeta("Blog | Notestack", "Field notes on research, deep writing and turning your archive into audio, video and launches.");
|
| 83 |
+
const posts = [...blogPosts].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
| 84 |
return (
|
| 85 |
<div className="page">
|
| 86 |
+
<SkyCanvas intensity={0.7} travel />
|
| 87 |
<PublicNav />
|
| 88 |
<main className="container blog-index">
|
| 89 |
+
<p className="eyebrow">Mission log</p>
|
| 90 |
+
<h1 className="section-title">Every post is a search for an answer</h1>
|
| 91 |
<p className="muted blog-lede">
|
| 92 |
+
Pick a question. Each mission follows it through a few waypoints until the answer comes into view: research, deep writing,
|
| 93 |
+
audio, video and distribution.
|
| 94 |
</p>
|
| 95 |
+
<ol className="mission-grid">
|
| 96 |
+
{posts.map((p) => {
|
| 97 |
+
const m = missionOf(p);
|
| 98 |
+
return (
|
| 99 |
+
<li key={p.slug}>
|
| 100 |
+
<Link to={`/blogs/${p.slug}`} className="card mission-card">
|
| 101 |
+
<span className="mission-meta mono">
|
| 102 |
+
<span className="mission-no">Mission {missionNumber(p)}</span>
|
| 103 |
+
<span>{p.category}</span>
|
| 104 |
+
</span>
|
| 105 |
+
<span className="mission-q">{m.question}</span>
|
| 106 |
+
<h2 className="blog-card-title">{p.title}</h2>
|
| 107 |
+
<span className="mission-foot mono muted">
|
| 108 |
+
{p.sections.length} waypoints · {p.readTime}
|
| 109 |
+
</span>
|
| 110 |
+
</Link>
|
| 111 |
+
</li>
|
| 112 |
+
);
|
| 113 |
+
})}
|
| 114 |
+
</ol>
|
| 115 |
</main>
|
| 116 |
<PublicFooter />
|
| 117 |
</div>
|
| 118 |
);
|
| 119 |
}
|
| 120 |
|
| 121 |
+
/** Tracks which waypoint the reader is at and how far along the flight path they are. */
|
| 122 |
+
function useFlight(count: number) {
|
| 123 |
+
const refs = useRef<(HTMLElement | null)[]>([]);
|
| 124 |
+
const pathRef = useRef<HTMLDivElement>(null);
|
| 125 |
+
const [active, setActive] = useState(0);
|
| 126 |
+
const [reached, setReached] = useState<Set<number>>(new Set());
|
| 127 |
+
const [progress, setProgress] = useState(0);
|
| 128 |
+
|
| 129 |
+
useEffect(() => {
|
| 130 |
+
const io = new IntersectionObserver(
|
| 131 |
+
(entries) => {
|
| 132 |
+
for (const e of entries) {
|
| 133 |
+
const idx = Number((e.target as HTMLElement).dataset.idx);
|
| 134 |
+
if (e.isIntersecting) {
|
| 135 |
+
setActive(idx);
|
| 136 |
+
setReached((r) => (r.has(idx) ? r : new Set(r).add(idx)));
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
{ rootMargin: "-35% 0px -55% 0px" },
|
| 141 |
+
);
|
| 142 |
+
refs.current.forEach((el) => el && io.observe(el));
|
| 143 |
+
const onScroll = () => {
|
| 144 |
+
const el = pathRef.current;
|
| 145 |
+
if (!el) return;
|
| 146 |
+
const rect = el.getBoundingClientRect();
|
| 147 |
+
const total = rect.height - window.innerHeight * 0.4;
|
| 148 |
+
setProgress(Math.max(0, Math.min(1, (window.innerHeight * 0.45 - rect.top) / Math.max(total, 1))));
|
| 149 |
+
};
|
| 150 |
+
onScroll();
|
| 151 |
+
window.addEventListener("scroll", onScroll, { passive: true });
|
| 152 |
+
return () => {
|
| 153 |
+
io.disconnect();
|
| 154 |
+
window.removeEventListener("scroll", onScroll);
|
| 155 |
+
};
|
| 156 |
+
}, [count]);
|
| 157 |
+
|
| 158 |
+
return { refs, pathRef, active, reached, progress };
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
export function BlogPostPage() {
|
| 162 |
const { slug = "" } = useParams();
|
| 163 |
const post = getPost(slug);
|
| 164 |
useMeta(post ? `${post.title} | Notestack` : "Not found | Notestack", post?.description ?? "", post?.canonicalPath);
|
| 165 |
+
const flight = useFlight(post?.sections.length ?? 0);
|
| 166 |
|
| 167 |
if (!post) {
|
| 168 |
return (
|
|
|
|
| 173 |
<h1>Lost in space</h1>
|
| 174 |
<p className="muted">That post drifted out of range.</p>
|
| 175 |
<Link to="/blogs" className="btn">
|
| 176 |
+
Back to the mission log
|
| 177 |
</Link>
|
| 178 |
</main>
|
| 179 |
</div>
|
| 180 |
);
|
| 181 |
}
|
| 182 |
|
| 183 |
+
const mission = missionOf(post);
|
| 184 |
+
const number = missionNumber(post);
|
| 185 |
const related = post.relatedPaths
|
| 186 |
.map((path) => blogPosts.find((p) => `/blogs/${p.slug}` === path))
|
| 187 |
.filter((p): p is NonNullable<typeof p> => Boolean(p));
|
|
|
|
| 192 |
headline: post.title,
|
| 193 |
description: post.description,
|
| 194 |
datePublished: post.publishedAt,
|
| 195 |
+
keywords: [post.primaryKeyword, post.keywordVariant].join(", "),
|
| 196 |
author: { "@type": "Organization", name: "Notestack" },
|
| 197 |
};
|
| 198 |
const faqLd = post.faq.length
|
|
|
|
| 202 |
mainEntity: post.faq.map((f) => ({
|
| 203 |
"@type": "Question",
|
| 204 |
name: f.question,
|
| 205 |
+
acceptedAnswer: { "@type": "Answer", text: plain(f.answer) },
|
| 206 |
})),
|
| 207 |
}
|
| 208 |
: null;
|
| 209 |
|
| 210 |
+
const goTo = (i: number) => flight.refs.current[i]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 211 |
+
|
| 212 |
return (
|
| 213 |
<div className="page">
|
| 214 |
+
<SkyCanvas intensity={0.6} travel />
|
| 215 |
<PublicNav />
|
| 216 |
+
<main className="container post mission">
|
| 217 |
<script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
|
| 218 |
{faqLd && <script type="application/ld+json">{JSON.stringify(faqLd)}</script>}
|
| 219 |
<Link to="/blogs" className="mono muted post-back">
|
| 220 |
+
Mission log / {post.category}
|
| 221 |
</Link>
|
| 222 |
+
|
| 223 |
+
<header className="mission-head">
|
| 224 |
+
<p className="eyebrow">
|
| 225 |
+
Mission {number} · {post.heroEyebrow}
|
| 226 |
+
</p>
|
| 227 |
+
<div className="transmission incoming">
|
| 228 |
+
<span className="mono tx-label">Incoming query</span>
|
| 229 |
+
<p className="tx-query">{mission.question}</p>
|
| 230 |
+
</div>
|
| 231 |
+
<h1 className="post-title">{post.title}</h1>
|
| 232 |
+
<p className="post-lede muted">{post.heroDescription}</p>
|
| 233 |
+
<p className="mono muted small">
|
| 234 |
+
{fmtDate(post.publishedAt)} · {post.readTime} · {post.sections.length} waypoints
|
| 235 |
+
</p>
|
| 236 |
+
<div className="answer-box">
|
| 237 |
+
<span className="mono tx-label">Short answer</span>
|
| 238 |
+
<p>
|
| 239 |
+
<Inline text={mission.answer} />
|
| 240 |
+
</p>
|
| 241 |
+
</div>
|
| 242 |
+
{post.heroImage && <img src={post.heroImage} alt={post.heroImageAlt ?? ""} className="post-hero" />}
|
| 243 |
+
</header>
|
| 244 |
+
|
| 245 |
+
<div className="flight">
|
| 246 |
+
<nav className="flight-map" aria-label="Waypoints">
|
| 247 |
+
<p className="mono tx-label">Flight plan</p>
|
| 248 |
+
<ol>
|
| 249 |
+
{post.sections.map((s, i) => (
|
| 250 |
+
<li key={s.heading} className={`${flight.active === i ? "on" : ""}${flight.reached.has(i) ? " done" : ""}`}>
|
| 251 |
+
<button type="button" onClick={() => goTo(i)}>
|
| 252 |
+
<span className="mono">{String(i + 1).padStart(2, "0")}</span>
|
| 253 |
+
{s.heading}
|
| 254 |
+
</button>
|
| 255 |
+
</li>
|
| 256 |
))}
|
| 257 |
+
<li className={flight.progress > 0.97 ? "on done" : ""}>
|
| 258 |
+
<button type="button" onClick={() => document.getElementById("destination")?.scrollIntoView({ behavior: "smooth" })}>
|
| 259 |
+
<span className="mono">*</span>
|
| 260 |
+
Answer found
|
| 261 |
+
</button>
|
| 262 |
+
</li>
|
| 263 |
+
</ol>
|
| 264 |
+
</nav>
|
| 265 |
+
|
| 266 |
+
<article className="post-body flight-path" ref={flight.pathRef}>
|
| 267 |
+
<div className="path-line" aria-hidden="true">
|
| 268 |
+
<div className="path-fill" style={{ height: `${flight.progress * 100}%` }} />
|
| 269 |
+
<div className="path-ship" style={{ top: `${flight.progress * 100}%` }} />
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
{post.sections.map((s, i) => (
|
| 273 |
+
<section
|
| 274 |
+
key={s.heading}
|
| 275 |
+
data-idx={i}
|
| 276 |
+
ref={(el) => (flight.refs.current[i] = el)}
|
| 277 |
+
className={`waypoint${flight.reached.has(i) ? " reached" : ""}`}
|
| 278 |
+
>
|
| 279 |
+
<span className="waypoint-node" aria-hidden="true" />
|
| 280 |
+
<p className="mono waypoint-label">Waypoint {String(i + 1).padStart(2, "0")}</p>
|
| 281 |
+
<h2>{s.heading}</h2>
|
| 282 |
+
{s.paragraphs.map((para, j) => (
|
| 283 |
+
<p key={j}>
|
| 284 |
+
<Inline text={para} />
|
| 285 |
+
</p>
|
| 286 |
+
))}
|
| 287 |
+
{s.bullets && (
|
| 288 |
+
<ul>
|
| 289 |
+
{s.bullets.map((b) => (
|
| 290 |
+
<li key={b}>
|
| 291 |
+
<Inline text={b} />
|
| 292 |
+
</li>
|
| 293 |
+
))}
|
| 294 |
+
</ul>
|
| 295 |
+
)}
|
| 296 |
+
{s.callout && (
|
| 297 |
+
<blockquote className="transmission">
|
| 298 |
+
<span className="mono tx-label">Transmission</span>
|
| 299 |
+
<Inline text={s.callout} />
|
| 300 |
+
</blockquote>
|
| 301 |
+
)}
|
| 302 |
+
</section>
|
| 303 |
+
))}
|
| 304 |
+
|
| 305 |
+
<section id="destination" className="waypoint destination">
|
| 306 |
+
<span className="waypoint-node" aria-hidden="true" />
|
| 307 |
+
<p className="mono waypoint-label">Destination</p>
|
| 308 |
+
<h2>Answer found</h2>
|
| 309 |
+
<div className="answer-box final">
|
| 310 |
+
<span className="mono tx-label">{mission.question}</span>
|
| 311 |
+
<p>
|
| 312 |
+
<Inline text={mission.answer} />
|
| 313 |
+
</p>
|
| 314 |
+
</div>
|
| 315 |
</section>
|
| 316 |
+
|
| 317 |
+
{post.faq.length > 0 && (
|
| 318 |
+
<section className="debrief">
|
| 319 |
+
<p className="mono waypoint-label">Mission debrief</p>
|
| 320 |
+
<h2>Questions from other travellers</h2>
|
| 321 |
+
{post.faq.map((f) => (
|
| 322 |
+
<details key={f.question} className="faq">
|
| 323 |
+
<summary>{f.question}</summary>
|
| 324 |
+
<p className="muted">
|
| 325 |
+
<Inline text={f.answer} />
|
| 326 |
+
</p>
|
| 327 |
+
</details>
|
| 328 |
+
))}
|
| 329 |
+
</section>
|
| 330 |
+
)}
|
| 331 |
+
</article>
|
| 332 |
+
</div>
|
| 333 |
|
| 334 |
<aside className="card post-cta">
|
| 335 |
+
<h3>Put your knowledge in orbit</h3>
|
| 336 |
+
<p className="muted">Connect any blog, newsletter or site, or upload markdown, and get grounded answers, audio and launch kits.</p>
|
| 337 |
<Link to="/auth?mode=signup" className="btn btn-primary">
|
| 338 |
Start free
|
| 339 |
</Link>
|
|
|
|
| 341 |
|
| 342 |
{related.length > 0 && (
|
| 343 |
<section className="post-related">
|
| 344 |
+
<p className="eyebrow">Next missions</p>
|
| 345 |
<div className="blog-grid">
|
| 346 |
{related.map((p) => (
|
| 347 |
+
<Fragment key={p.slug}>
|
| 348 |
+
<Link to={`/blogs/${p.slug}`} className="card blog-card">
|
| 349 |
+
<span className="mono mission-no">Mission {missionNumber(p)}</span>
|
| 350 |
+
<h3>{p.title}</h3>
|
| 351 |
+
<p className="muted">{missionOf(p).question}</p>
|
| 352 |
+
</Link>
|
| 353 |
+
</Fragment>
|
| 354 |
))}
|
| 355 |
</div>
|
| 356 |
</section>
|
frontend/src/pages/Landing.tsx
CHANGED
|
@@ -59,7 +59,7 @@ const FEATURES = [
|
|
| 59 |
];
|
| 60 |
|
| 61 |
const STEPS = [
|
| 62 |
-
{ n: "01", title: "
|
| 63 |
{ n: "02", title: "Watch them come into orbit", body: "Posts are cleaned, indexed and embedded in minutes." },
|
| 64 |
{ n: "03", title: "Launch", body: "Ask, listen, render and publish, all grounded in what you wrote." },
|
| 65 |
];
|
|
@@ -83,7 +83,7 @@ function DemoVideo() {
|
|
| 83 |
return (
|
| 84 |
<div className="demo-frame card arrive">
|
| 85 |
<div className="demo-bar mono">
|
| 86 |
-
<span>Mission log · 00:
|
| 87 |
<button type="button" className="demo-sound" onClick={toggle} aria-pressed={!muted}>
|
| 88 |
{muted ? "Play with sound" : "Mute"}
|
| 89 |
</button>
|
|
@@ -99,7 +99,7 @@ function DemoVideo() {
|
|
| 99 |
loop
|
| 100 |
playsInline
|
| 101 |
preload="metadata"
|
| 102 |
-
aria-label="Notestack product demo, narrated:
|
| 103 |
/>
|
| 104 |
{muted && (
|
| 105 |
<button type="button" className="demo-unmute" onClick={toggle} aria-label="Play the demo with sound">
|
|
@@ -168,24 +168,24 @@ export default function Landing() {
|
|
| 168 |
<main>
|
| 169 |
<section className="hero">
|
| 170 |
<div className="container hero-inner">
|
| 171 |
-
<p className="eyebrow">For
|
| 172 |
<h1 className="hero-title">
|
| 173 |
-
Your
|
| 174 |
<br />
|
| 175 |
<span className="glow-text shimmer">in orbit.</span>
|
| 176 |
</h1>
|
| 177 |
<p className="hero-sub muted">
|
| 178 |
-
Notestack turns your
|
| 179 |
-
launch kits for every platform. Every word traced back to what you actually wrote.
|
| 180 |
</p>
|
| 181 |
<form className="hero-form" onSubmit={onSubmit}>
|
| 182 |
<label htmlFor="hero-url" className="sr-only">
|
| 183 |
-
Your
|
| 184 |
</label>
|
| 185 |
<input
|
| 186 |
id="hero-url"
|
| 187 |
className="input"
|
| 188 |
-
placeholder="yourname.substack.com"
|
| 189 |
value={url}
|
| 190 |
onChange={(e) => setUrl(e.target.value)}
|
| 191 |
autoComplete="url"
|
|
@@ -196,7 +196,10 @@ export default function Landing() {
|
|
| 196 |
<ArrowRightIcon size={18} />
|
| 197 |
</button>
|
| 198 |
</form>
|
| 199 |
-
<p className="mono hero-meta">
|
|
|
|
|
|
|
|
|
|
| 200 |
</div>
|
| 201 |
<div className="horizon" aria-hidden="true">
|
| 202 |
<span className="horizon-flare" />
|
|
|
|
| 59 |
];
|
| 60 |
|
| 61 |
const STEPS = [
|
| 62 |
+
{ n: "01", title: "Point it at your writing", body: "Any blog, newsletter or site URL, or upload markdown, text, HTML and PDF files." },
|
| 63 |
{ n: "02", title: "Watch them come into orbit", body: "Posts are cleaned, indexed and embedded in minutes." },
|
| 64 |
{ n: "03", title: "Launch", body: "Ask, listen, render and publish, all grounded in what you wrote." },
|
| 65 |
];
|
|
|
|
| 83 |
return (
|
| 84 |
<div className="demo-frame card arrive">
|
| 85 |
<div className="demo-bar mono">
|
| 86 |
+
<span>Mission log · 00:46</span>
|
| 87 |
<button type="button" className="demo-sound" onClick={toggle} aria-pressed={!muted}>
|
| 88 |
{muted ? "Play with sound" : "Mute"}
|
| 89 |
</button>
|
|
|
|
| 99 |
loop
|
| 100 |
playsInline
|
| 101 |
preload="metadata"
|
| 102 |
+
aria-label="Notestack product demo, narrated: connect any blog, site or markdown, ask your archive, hear an audio overview and launch posts"
|
| 103 |
/>
|
| 104 |
{muted && (
|
| 105 |
<button type="button" className="demo-unmute" onClick={toggle} aria-label="Play the demo with sound">
|
|
|
|
| 168 |
<main>
|
| 169 |
<section className="hero">
|
| 170 |
<div className="container hero-inner">
|
| 171 |
+
<p className="eyebrow">For writers, bloggers and newsletters</p>
|
| 172 |
<h1 className="hero-title">
|
| 173 |
+
Your knowledge,
|
| 174 |
<br />
|
| 175 |
<span className="glow-text shimmer">in orbit.</span>
|
| 176 |
</h1>
|
| 177 |
<p className="hero-sub muted">
|
| 178 |
+
Notestack turns your blog, newsletter, site or markdown files into a research notebook you can question,
|
| 179 |
+
podcasts you can publish and launch kits for every platform. Every word traced back to what you actually wrote.
|
| 180 |
</p>
|
| 181 |
<form className="hero-form" onSubmit={onSubmit}>
|
| 182 |
<label htmlFor="hero-url" className="sr-only">
|
| 183 |
+
Your blog, newsletter or site URL
|
| 184 |
</label>
|
| 185 |
<input
|
| 186 |
id="hero-url"
|
| 187 |
className="input"
|
| 188 |
+
placeholder="yourblog.com or yourname.substack.com"
|
| 189 |
value={url}
|
| 190 |
onChange={(e) => setUrl(e.target.value)}
|
| 191 |
autoComplete="url"
|
|
|
|
| 196 |
<ArrowRightIcon size={18} />
|
| 197 |
</button>
|
| 198 |
</form>
|
| 199 |
+
<p className="mono hero-meta">
|
| 200 |
+
Works with Substack, Ghost, WordPress, Medium, any RSS feed or site, and markdown files.{" "}
|
| 201 |
+
<Link to="/auth?mode=signup">Upload files instead</Link>
|
| 202 |
+
</p>
|
| 203 |
</div>
|
| 204 |
<div className="horizon" aria-hidden="true">
|
| 205 |
<span className="horizon-flare" />
|
frontend/src/pages/Sources.tsx
CHANGED
|
@@ -55,7 +55,7 @@ export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) =>
|
|
| 55 |
}}
|
| 56 |
/>
|
| 57 |
<p className="muted">
|
| 58 |
-
{mode === "feed" && "Substack, Ghost,
|
| 59 |
{mode === "url" && "One article from anywhere on the web. It lands in Imported posts."}
|
| 60 |
{mode === "file" && "Markdown, text, HTML or PDF. Handy for drafts and posts that never had a feed."}
|
| 61 |
</p>
|
|
@@ -65,7 +65,7 @@ export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) =>
|
|
| 65 |
) : (
|
| 66 |
<input
|
| 67 |
className="input"
|
| 68 |
-
placeholder={mode === "feed" ? "
|
| 69 |
value={value}
|
| 70 |
onChange={(e) => setValue(e.target.value)}
|
| 71 |
aria-label={mode === "feed" ? "Feed or site URL" : "Article URL"}
|
|
@@ -144,7 +144,7 @@ export default function Sources() {
|
|
| 144 |
|
| 145 |
return (
|
| 146 |
<div className="page-wrap">
|
| 147 |
-
<PageHeader eyebrow="Sources" title="Your
|
| 148 |
<AddSource onAdded={added} />
|
| 149 |
{error && <p className="error-text">{error}</p>}
|
| 150 |
|
|
@@ -205,7 +205,7 @@ export default function Sources() {
|
|
| 205 |
})}
|
| 206 |
</section>
|
| 207 |
{sources?.length === 0 && (
|
| 208 |
-
<EmptyState title="No sources yet" body="A lone satellite, waiting for signal. Connect
|
| 209 |
)}
|
| 210 |
|
| 211 |
{sources && sources.length > 0 && (
|
|
|
|
| 55 |
}}
|
| 56 |
/>
|
| 57 |
<p className="muted">
|
| 58 |
+
{mode === "feed" && "Any blog, newsletter or site: Substack, Ghost, WordPress, Medium or anything with an RSS feed. We find the feed and pull in your archive."}
|
| 59 |
{mode === "url" && "One article from anywhere on the web. It lands in Imported posts."}
|
| 60 |
{mode === "file" && "Markdown, text, HTML or PDF. Handy for drafts and posts that never had a feed."}
|
| 61 |
</p>
|
|
|
|
| 65 |
) : (
|
| 66 |
<input
|
| 67 |
className="input"
|
| 68 |
+
placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
|
| 69 |
value={value}
|
| 70 |
onChange={(e) => setValue(e.target.value)}
|
| 71 |
aria-label={mode === "feed" ? "Feed or site URL" : "Article URL"}
|
|
|
|
| 144 |
|
| 145 |
return (
|
| 146 |
<div className="page-wrap">
|
| 147 |
+
<PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
|
| 148 |
<AddSource onAdded={added} />
|
| 149 |
{error && <p className="error-text">{error}</p>}
|
| 150 |
|
|
|
|
| 205 |
})}
|
| 206 |
</section>
|
| 207 |
{sources?.length === 0 && (
|
| 208 |
+
<EmptyState title="No sources yet" body="A lone satellite, waiting for signal. Connect a blog or site above, or upload your markdown files." />
|
| 209 |
)}
|
| 210 |
|
| 211 |
{sources && sources.length > 0 && (
|
frontend/src/pages/Welcome.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import { PENDING_SOURCE_KEY } from "./Landing";
|
|
| 12 |
type Step = "connect" | "indexing" | "goal" | "ready";
|
| 13 |
type Goal = "ask" | "audio" | "launch";
|
| 14 |
|
| 15 |
-
const EXAMPLES = ["
|
| 16 |
|
| 17 |
const GOALS: { id: Goal; title: string; body: string }[] = [
|
| 18 |
{ id: "ask", title: "Ask my archive", body: "Chat with everything you have written. Every answer cites the lines it came from." },
|
|
@@ -138,17 +138,17 @@ export default function Welcome() {
|
|
| 138 |
<p className="eyebrow">Welcome aboard, {first}</p>
|
| 139 |
<h1>Where do you publish?</h1>
|
| 140 |
<p className="muted">
|
| 141 |
-
Paste
|
| 142 |
question, hear and launch from.
|
| 143 |
</p>
|
| 144 |
<form onSubmit={submit} className="welcome-form">
|
| 145 |
<input
|
| 146 |
className="input"
|
| 147 |
autoFocus
|
| 148 |
-
placeholder="
|
| 149 |
value={url}
|
| 150 |
onChange={(e) => setUrl(e.target.value)}
|
| 151 |
-
aria-label="Blog or
|
| 152 |
/>
|
| 153 |
<button className="btn btn-primary" disabled={busy || !url.trim()}>
|
| 154 |
{busy ? "Finding your feed..." : "Connect"}
|
|
@@ -168,7 +168,7 @@ export default function Welcome() {
|
|
| 168 |
</p>
|
| 169 |
)}
|
| 170 |
<p className="muted small">
|
| 171 |
-
|
| 172 |
</p>
|
| 173 |
</section>
|
| 174 |
)}
|
|
|
|
| 12 |
type Step = "connect" | "indexing" | "goal" | "ready";
|
| 13 |
type Goal = "ask" | "audio" | "launch";
|
| 14 |
|
| 15 |
+
const EXAMPLES = ["yourblog.com", "yourname.substack.com", "medium.com/@you"];
|
| 16 |
|
| 17 |
const GOALS: { id: Goal; title: string; body: string }[] = [
|
| 18 |
{ id: "ask", title: "Ask my archive", body: "Chat with everything you have written. Every answer cites the lines it came from." },
|
|
|
|
| 138 |
<p className="eyebrow">Welcome aboard, {first}</p>
|
| 139 |
<h1>Where do you publish?</h1>
|
| 140 |
<p className="muted">
|
| 141 |
+
Paste any blog, newsletter or site: Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and turn it into a research notebook you can
|
| 142 |
question, hear and launch from.
|
| 143 |
</p>
|
| 144 |
<form onSubmit={submit} className="welcome-form">
|
| 145 |
<input
|
| 146 |
className="input"
|
| 147 |
autoFocus
|
| 148 |
+
placeholder="yourblog.com"
|
| 149 |
value={url}
|
| 150 |
onChange={(e) => setUrl(e.target.value)}
|
| 151 |
+
aria-label="Blog, newsletter or site address"
|
| 152 |
/>
|
| 153 |
<button className="btn btn-primary" disabled={busy || !url.trim()}>
|
| 154 |
{busy ? "Finding your feed..." : "Connect"}
|
|
|
|
| 168 |
</p>
|
| 169 |
)}
|
| 170 |
<p className="muted small">
|
| 171 |
+
Writing in markdown or no feed? <Link to="/app/sources">Upload markdown, text, HTML or PDF files, or import single pages</Link> instead.
|
| 172 |
</p>
|
| 173 |
</section>
|
| 174 |
)}
|
frontend/src/styles/blog.css
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ---------- Blog as missions: every post is a search for an answer ---------- */
|
| 2 |
+
|
| 3 |
+
/* Index: mission log */
|
| 4 |
+
.mission-grid {
|
| 5 |
+
list-style: none;
|
| 6 |
+
margin: 0;
|
| 7 |
+
padding: 0;
|
| 8 |
+
display: grid;
|
| 9 |
+
gap: 16px;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
@media (min-width: 760px) {
|
| 13 |
+
.mission-grid {
|
| 14 |
+
grid-template-columns: repeat(2, 1fr);
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
@media (min-width: 1100px) {
|
| 19 |
+
.mission-grid {
|
| 20 |
+
grid-template-columns: repeat(3, 1fr);
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.mission-card {
|
| 25 |
+
position: relative;
|
| 26 |
+
display: grid;
|
| 27 |
+
gap: 12px;
|
| 28 |
+
height: 100%;
|
| 29 |
+
align-content: start;
|
| 30 |
+
text-decoration: none;
|
| 31 |
+
overflow: hidden;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.mission-card::before {
|
| 35 |
+
content: "";
|
| 36 |
+
position: absolute;
|
| 37 |
+
right: -40px;
|
| 38 |
+
top: -40px;
|
| 39 |
+
width: 140px;
|
| 40 |
+
height: 140px;
|
| 41 |
+
border-radius: 50%;
|
| 42 |
+
background: radial-gradient(circle, var(--blue-20), transparent 70%);
|
| 43 |
+
transition: transform 0.4s ease;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.mission-card:hover::before {
|
| 47 |
+
transform: scale(1.4);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.mission-meta {
|
| 51 |
+
display: flex;
|
| 52 |
+
justify-content: space-between;
|
| 53 |
+
gap: 8px;
|
| 54 |
+
font-size: 0.7rem;
|
| 55 |
+
color: var(--text-3);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.mission-no {
|
| 59 |
+
color: var(--blue);
|
| 60 |
+
letter-spacing: 0.14em;
|
| 61 |
+
text-transform: uppercase;
|
| 62 |
+
font-size: 0.7rem;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.mission-q {
|
| 66 |
+
font-family: var(--font-display);
|
| 67 |
+
font-size: 1.15rem;
|
| 68 |
+
line-height: 1.3;
|
| 69 |
+
color: var(--white);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.mission-q::before {
|
| 73 |
+
content: "> ";
|
| 74 |
+
color: var(--blue);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.mission-card .blog-card-title {
|
| 78 |
+
font-size: 0.95rem;
|
| 79 |
+
font-weight: 500;
|
| 80 |
+
color: var(--text-2);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.mission-foot {
|
| 84 |
+
margin-top: auto;
|
| 85 |
+
font-size: 0.7rem;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* Post: header */
|
| 89 |
+
.post.mission {
|
| 90 |
+
max-width: 1080px;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.mission-head {
|
| 94 |
+
display: grid;
|
| 95 |
+
gap: 14px;
|
| 96 |
+
max-width: 780px;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.mission-head .post-title {
|
| 100 |
+
margin: 4px 0 0;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.tx-label {
|
| 104 |
+
display: block;
|
| 105 |
+
font-size: 0.68rem;
|
| 106 |
+
letter-spacing: 0.16em;
|
| 107 |
+
text-transform: uppercase;
|
| 108 |
+
color: var(--blue);
|
| 109 |
+
margin-bottom: 6px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.transmission.incoming {
|
| 113 |
+
padding: 14px 18px;
|
| 114 |
+
border: 1px solid var(--blue-35);
|
| 115 |
+
border-radius: var(--radius-sm);
|
| 116 |
+
background: linear-gradient(90deg, var(--blue-10), transparent 70%);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.tx-query {
|
| 120 |
+
font-family: var(--font-mono);
|
| 121 |
+
font-size: 1.05rem;
|
| 122 |
+
color: var(--white);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.tx-query::after {
|
| 126 |
+
content: "_";
|
| 127 |
+
margin-left: 2px;
|
| 128 |
+
color: var(--blue);
|
| 129 |
+
animation: caret 1.1s steps(1) infinite;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
@keyframes caret {
|
| 133 |
+
50% {
|
| 134 |
+
opacity: 0;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.answer-box {
|
| 139 |
+
padding: 16px 20px;
|
| 140 |
+
border-radius: var(--radius);
|
| 141 |
+
border: 1px solid var(--line-strong);
|
| 142 |
+
background: rgba(0, 0, 0, 0.55);
|
| 143 |
+
box-shadow: inset 3px 0 0 var(--blue);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.answer-box p {
|
| 147 |
+
color: var(--white);
|
| 148 |
+
font-size: 1.05rem;
|
| 149 |
+
line-height: 1.6;
|
| 150 |
+
margin: 0;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.answer-box.final {
|
| 154 |
+
border-color: var(--blue-60);
|
| 155 |
+
box-shadow: inset 3px 0 0 var(--blue), var(--glow-lg);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* Post: flight path */
|
| 159 |
+
.flight {
|
| 160 |
+
display: grid;
|
| 161 |
+
gap: 24px;
|
| 162 |
+
margin-top: 40px;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
@media (min-width: 1100px) {
|
| 166 |
+
.flight {
|
| 167 |
+
grid-template-columns: 240px minmax(0, 1fr);
|
| 168 |
+
gap: 48px;
|
| 169 |
+
align-items: start;
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.flight-map {
|
| 174 |
+
display: none;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
@media (min-width: 1100px) {
|
| 178 |
+
.flight-map {
|
| 179 |
+
display: block;
|
| 180 |
+
position: sticky;
|
| 181 |
+
top: 96px;
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.flight-map ol {
|
| 186 |
+
list-style: none;
|
| 187 |
+
margin: 0;
|
| 188 |
+
padding: 0;
|
| 189 |
+
display: grid;
|
| 190 |
+
gap: 2px;
|
| 191 |
+
border-left: 1px solid var(--line);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.flight-map button {
|
| 195 |
+
display: flex;
|
| 196 |
+
gap: 10px;
|
| 197 |
+
width: 100%;
|
| 198 |
+
padding: 7px 10px;
|
| 199 |
+
margin-left: -1px;
|
| 200 |
+
border: none;
|
| 201 |
+
border-left: 2px solid transparent;
|
| 202 |
+
background: transparent;
|
| 203 |
+
color: var(--text-3);
|
| 204 |
+
font: 0.85rem/1.35 var(--font-body);
|
| 205 |
+
text-align: left;
|
| 206 |
+
cursor: pointer;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.flight-map button .mono {
|
| 210 |
+
font-size: 0.68rem;
|
| 211 |
+
padding-top: 2px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.flight-map li.done button {
|
| 215 |
+
color: var(--text-2);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.flight-map li.on button {
|
| 219 |
+
color: var(--white);
|
| 220 |
+
border-left-color: var(--blue);
|
| 221 |
+
background: linear-gradient(90deg, var(--blue-10), transparent);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.flight-path {
|
| 225 |
+
position: relative;
|
| 226 |
+
padding-left: 40px;
|
| 227 |
+
margin-top: 0;
|
| 228 |
+
max-width: 760px;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.path-line {
|
| 232 |
+
position: absolute;
|
| 233 |
+
left: 11px;
|
| 234 |
+
top: 6px;
|
| 235 |
+
bottom: 0;
|
| 236 |
+
width: 2px;
|
| 237 |
+
background: repeating-linear-gradient(180deg, var(--line-strong) 0 6px, transparent 6px 12px);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.path-fill {
|
| 241 |
+
width: 100%;
|
| 242 |
+
background: linear-gradient(180deg, var(--blue-35), var(--blue));
|
| 243 |
+
box-shadow: 0 0 10px var(--blue-60);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.path-ship {
|
| 247 |
+
position: absolute;
|
| 248 |
+
left: 50%;
|
| 249 |
+
width: 14px;
|
| 250 |
+
height: 14px;
|
| 251 |
+
margin: -7px 0 0 -7px;
|
| 252 |
+
border-radius: 50%;
|
| 253 |
+
background: var(--white);
|
| 254 |
+
box-shadow: 0 0 12px var(--white), 0 0 24px var(--blue);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.path-ship::after {
|
| 258 |
+
content: "";
|
| 259 |
+
position: absolute;
|
| 260 |
+
left: 50%;
|
| 261 |
+
bottom: 100%;
|
| 262 |
+
width: 2px;
|
| 263 |
+
height: 28px;
|
| 264 |
+
margin-left: -1px;
|
| 265 |
+
background: linear-gradient(0deg, var(--white), transparent);
|
| 266 |
+
opacity: 0.6;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.waypoint {
|
| 270 |
+
position: relative;
|
| 271 |
+
margin-bottom: 56px;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.waypoint-node {
|
| 275 |
+
position: absolute;
|
| 276 |
+
left: -36px;
|
| 277 |
+
top: 4px;
|
| 278 |
+
width: 16px;
|
| 279 |
+
height: 16px;
|
| 280 |
+
border-radius: 50%;
|
| 281 |
+
border: 2px solid var(--line-strong);
|
| 282 |
+
background: var(--black);
|
| 283 |
+
transition: all 0.4s ease;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.waypoint.reached .waypoint-node {
|
| 287 |
+
border-color: var(--blue);
|
| 288 |
+
background: var(--blue);
|
| 289 |
+
box-shadow: 0 0 14px var(--blue);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.waypoint-label {
|
| 293 |
+
font-size: 0.68rem;
|
| 294 |
+
letter-spacing: 0.16em;
|
| 295 |
+
text-transform: uppercase;
|
| 296 |
+
color: var(--text-3);
|
| 297 |
+
margin-bottom: 4px;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.waypoint.reached .waypoint-label {
|
| 301 |
+
color: var(--blue);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.post-body a {
|
| 305 |
+
color: var(--white);
|
| 306 |
+
text-decoration: underline;
|
| 307 |
+
text-decoration-color: var(--blue);
|
| 308 |
+
text-underline-offset: 3px;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.post-body a:hover {
|
| 312 |
+
text-shadow: 0 0 12px var(--blue-60);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.post-body strong {
|
| 316 |
+
color: var(--white);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.post-body blockquote.transmission {
|
| 320 |
+
display: block;
|
| 321 |
+
font-size: 1.1rem;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.destination .waypoint-node {
|
| 325 |
+
width: 22px;
|
| 326 |
+
height: 22px;
|
| 327 |
+
left: -39px;
|
| 328 |
+
top: 0;
|
| 329 |
+
border-color: var(--white);
|
| 330 |
+
background: radial-gradient(circle, #fff 20%, var(--blue) 60%);
|
| 331 |
+
box-shadow: 0 0 20px var(--blue), 0 0 40px var(--blue-35);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.debrief {
|
| 335 |
+
margin-top: 16px;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
@media (prefers-reduced-motion: reduce) {
|
| 339 |
+
.tx-query::after {
|
| 340 |
+
animation: none;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.waypoint-node,
|
| 344 |
+
.mission-card::before {
|
| 345 |
+
transition: none;
|
| 346 |
+
}
|
| 347 |
+
}
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
frontend/vite.config.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
| 1 |
import { defineConfig } from "vite";
|
| 2 |
import react from "@vitejs/plugin-react";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export default defineConfig({
|
| 5 |
-
plugins: [react()],
|
| 6 |
// Read the shared root .env. Only these prefixes reach the browser; the Google client ID is
|
| 7 |
// public by design, so it can be shared with the backend under its plain name.
|
| 8 |
envDir: "..",
|
|
|
|
| 1 |
import { defineConfig } from "vite";
|
| 2 |
import react from "@vitejs/plugin-react";
|
| 3 |
+
import { sitemapPlugin } from "./seo/sitemap";
|
| 4 |
+
|
| 5 |
+
// Public origin for sitemap.xml and robots.txt. Set SITE_URL in the deploy environment.
|
| 6 |
+
const SITE_URL = process.env.SITE_URL ?? "https://notestack.ai";
|
| 7 |
|
| 8 |
export default defineConfig({
|
| 9 |
+
plugins: [react(), sitemapPlugin(SITE_URL)],
|
| 10 |
// Read the shared root .env. Only these prefixes reach the browser; the Google client ID is
|
| 11 |
// public by design, so it can be shared with the backend under its plain name.
|
| 12 |
envDir: "..",
|
renderer/public/demo-vo/durations.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
-
"warp": 3.
|
| 3 |
-
"paste":
|
| 4 |
"orbit": 6.3,
|
| 5 |
"research": 4.29,
|
| 6 |
"audio": 3.74,
|
|
|
|
| 1 |
{
|
| 2 |
+
"warp": 3.74,
|
| 3 |
+
"paste": 8.1,
|
| 4 |
"orbit": 6.3,
|
| 5 |
"research": 4.29,
|
| 6 |
"audio": 3.74,
|
renderer/src/Root.tsx
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
| 20 |
const sampleScenes: ShortVerticalProps["scenes"] = [
|
| 21 |
{ type: "section", onScreenText: "You have written hundreds of thousands of words.", narration: "", durationS: 3 },
|
| 22 |
{ type: "pull_quote", onScreenText: "If a host says it, you wrote it.", narration: "", durationS: 3, citation: "Notestack blog" },
|
| 23 |
-
{ type: "outro", onScreenText: "
|
| 24 |
];
|
| 25 |
|
| 26 |
const sampleShort: ShortVerticalProps = {
|
|
|
|
| 20 |
const sampleScenes: ShortVerticalProps["scenes"] = [
|
| 21 |
{ type: "section", onScreenText: "You have written hundreds of thousands of words.", narration: "", durationS: 3 },
|
| 22 |
{ type: "pull_quote", onScreenText: "If a host says it, you wrote it.", narration: "", durationS: 3, citation: "Notestack blog" },
|
| 23 |
+
{ type: "outro", onScreenText: "Connect your writing. Launch.", narration: "", durationS: 2.5 },
|
| 24 |
];
|
| 25 |
|
| 26 |
const sampleShort: ShortVerticalProps = {
|
renderer/src/compositions/LandingDemo.tsx
CHANGED
|
@@ -13,7 +13,7 @@ const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
|
|
| 13 |
// each line starts `vo` seconds into its scene and ends with room to breathe before the crossfade.
|
| 14 |
const SCENES = [
|
| 15 |
{ id: "warp", len: 5.2, vo: 0.6 },
|
| 16 |
-
{ id: "paste", len:
|
| 17 |
{ id: "orbit", len: 7.6, vo: 0.4 },
|
| 18 |
{ id: "research", len: 5.6, vo: 0.4 },
|
| 19 |
{ id: "audio", len: 5.1, vo: 0.4 },
|
|
@@ -25,7 +25,7 @@ const XFADE = 0.5; // seconds of overlap between scenes
|
|
| 25 |
|
| 26 |
// Narration clip lengths (public/demo-vo/durations.json) so the music can duck under the voice.
|
| 27 |
const VO_SECONDS: Record<(typeof SCENES)[number]["id"], number> = {
|
| 28 |
-
warp: 3.
|
| 29 |
};
|
| 30 |
|
| 31 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
|
@@ -188,7 +188,7 @@ function Warp() {
|
|
| 188 |
<WarpField speed={speed} streak={streak} />
|
| 189 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
|
| 190 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 150, lineHeight: 1, color: theme.white, opacity: title, transform: `scale(${0.8 + title * 0.2})` }}>
|
| 191 |
-
Your
|
| 192 |
<br />
|
| 193 |
<span style={{ color: theme.white, textShadow: `0 0 30px ${BLUE}, 0 0 80px ${BLUE}` }}>in orbit.</span>
|
| 194 |
</h1>
|
|
@@ -201,20 +201,24 @@ function Warp() {
|
|
| 201 |
);
|
| 202 |
}
|
| 203 |
|
|
|
|
|
|
|
|
|
|
| 204 |
function Paste() {
|
| 205 |
const frame = useCurrentFrame();
|
| 206 |
const { fps } = useVideoConfig();
|
| 207 |
-
const url = typed("
|
| 208 |
-
const
|
| 209 |
-
const
|
|
|
|
| 210 |
return (
|
| 211 |
<AbsoluteFill>
|
| 212 |
<WarpField speed={1.2} />
|
| 213 |
-
<Caption eyebrow="Step 01" title="
|
| 214 |
-
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
| 215 |
-
<div style={{ ...panel, display: "flex", alignItems: "center", gap: 18, padding: 14, width: 1100, borderRadius: 999
|
| 216 |
<div style={{ flex: 1, fontFamily: theme.body, fontSize: 40, color: url ? theme.white : "rgba(255,255,255,0.4)", paddingLeft: 30 }}>
|
| 217 |
-
{url || "
|
| 218 |
<span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
|
| 219 |
</div>
|
| 220 |
<div
|
|
@@ -232,15 +236,54 @@ function Paste() {
|
|
| 232 |
}}
|
| 233 |
>
|
| 234 |
Launch my archive
|
| 235 |
-
<div
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
</div>
|
| 246 |
</AbsoluteFill>
|
|
@@ -524,7 +567,7 @@ function Outro() {
|
|
| 524 |
<div style={{ width: 44, height: 44, borderRadius: "50%", background: theme.black, boxShadow: `18px -18px 0 -10px ${theme.black}` }} />
|
| 525 |
</div>
|
| 526 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 120, color: theme.white, opacity: s, textShadow: `0 0 40px ${BLUE}` }}>Notestack</h1>
|
| 527 |
-
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 40, color: "rgba(255,255,255,0.8)", opacity: cta }}>Your
|
| 528 |
<p style={{ margin: 0, padding: "18px 40px", borderRadius: 999, background: BLUE, color: theme.white, fontFamily: theme.body, fontWeight: 600, fontSize: 32, opacity: cta, boxShadow: `0 0 40px ${BLUE}` }}>
|
| 529 |
Free during early access
|
| 530 |
</p>
|
|
|
|
| 13 |
// each line starts `vo` seconds into its scene and ends with room to breathe before the crossfade.
|
| 14 |
const SCENES = [
|
| 15 |
{ id: "warp", len: 5.2, vo: 0.6 },
|
| 16 |
+
{ id: "paste", len: 9.4, vo: 0.4 },
|
| 17 |
{ id: "orbit", len: 7.6, vo: 0.4 },
|
| 18 |
{ id: "research", len: 5.6, vo: 0.4 },
|
| 19 |
{ id: "audio", len: 5.1, vo: 0.4 },
|
|
|
|
| 25 |
|
| 26 |
// Narration clip lengths (public/demo-vo/durations.json) so the music can duck under the voice.
|
| 27 |
const VO_SECONDS: Record<(typeof SCENES)[number]["id"], number> = {
|
| 28 |
+
warp: 3.74, paste: 8.1, orbit: 6.3, research: 4.29, audio: 3.74, launchkit: 6.53, launchpad: 3.22, outro: 2.48,
|
| 29 |
};
|
| 30 |
|
| 31 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
|
|
|
| 188 |
<WarpField speed={speed} streak={streak} />
|
| 189 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
|
| 190 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 150, lineHeight: 1, color: theme.white, opacity: title, transform: `scale(${0.8 + title * 0.2})` }}>
|
| 191 |
+
Your knowledge,
|
| 192 |
<br />
|
| 193 |
<span style={{ color: theme.white, textShadow: `0 0 30px ${BLUE}, 0 0 80px ${BLUE}` }}>in orbit.</span>
|
| 194 |
</h1>
|
|
|
|
| 201 |
);
|
| 202 |
}
|
| 203 |
|
| 204 |
+
const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "Any RSS feed", "Your site", "Markdown files"];
|
| 205 |
+
|
| 206 |
+
/** Step one: any blog, newsletter or site by URL, or markdown files dropped straight in. */
|
| 207 |
function Paste() {
|
| 208 |
const frame = useCurrentFrame();
|
| 209 |
const { fps } = useVideoConfig();
|
| 210 |
+
const url = typed("yourblog.com", frame, 14, 12);
|
| 211 |
+
const drop = spring({ frame: frame - S(5.4), fps, config: { damping: 12 } });
|
| 212 |
+
const press = spring({ frame: frame - S(7.6), fps, config: { damping: 10, mass: 0.5 } });
|
| 213 |
+
const ring = interpolate(frame, [S(7.7), S(8.9)], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
| 214 |
return (
|
| 215 |
<AbsoluteFill>
|
| 216 |
<WarpField speed={1.2} />
|
| 217 |
+
<Caption eyebrow="Step 01" title="Point it at your writing" />
|
| 218 |
+
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", gap: 44, paddingTop: 120 }}>
|
| 219 |
+
<div style={{ ...panel, display: "flex", alignItems: "center", gap: 18, padding: 14, width: 1100, borderRadius: 999 }}>
|
| 220 |
<div style={{ flex: 1, fontFamily: theme.body, fontSize: 40, color: url ? theme.white : "rgba(255,255,255,0.4)", paddingLeft: 30 }}>
|
| 221 |
+
{url || "yourblog.com"}
|
| 222 |
<span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
|
| 223 |
</div>
|
| 224 |
<div
|
|
|
|
| 236 |
}}
|
| 237 |
>
|
| 238 |
Launch my archive
|
| 239 |
+
<div style={{ position: "absolute", inset: -ring * 60, borderRadius: 999, border: `2px solid ${BLUE}`, opacity: 1 - ring }} />
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: 14, maxWidth: 1300 }}>
|
| 244 |
+
{SOURCES.map((name, i) => {
|
| 245 |
+
const on = spring({ frame: frame - S(1.8) - i * 7, fps, config: { damping: 16 } });
|
| 246 |
+
return (
|
| 247 |
+
<span
|
| 248 |
+
key={name}
|
| 249 |
+
style={{
|
| 250 |
+
padding: "12px 24px",
|
| 251 |
+
borderRadius: 999,
|
| 252 |
+
border: `1px solid ${on > 0.5 ? BLUE : "rgba(255,255,255,0.18)"}`,
|
| 253 |
+
background: on > 0.5 ? `${BLUE}26` : "rgba(0,0,0,0.5)",
|
| 254 |
+
boxShadow: on > 0.5 ? `0 0 18px ${BLUE}66` : "none",
|
| 255 |
+
fontFamily: theme.mono,
|
| 256 |
+
fontSize: 24,
|
| 257 |
+
letterSpacing: 1,
|
| 258 |
+
color: theme.white,
|
| 259 |
+
opacity: 0.25 + on * 0.75,
|
| 260 |
+
transform: `translateY(${(1 - on) * 16}px)`,
|
| 261 |
+
}}
|
| 262 |
+
>
|
| 263 |
+
{name}
|
| 264 |
+
</span>
|
| 265 |
+
);
|
| 266 |
+
})}
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
+
<div
|
| 270 |
+
style={{
|
| 271 |
+
...panel,
|
| 272 |
+
display: "flex",
|
| 273 |
+
alignItems: "center",
|
| 274 |
+
gap: 22,
|
| 275 |
+
padding: "18px 28px",
|
| 276 |
+
borderRadius: 16,
|
| 277 |
+
opacity: drop,
|
| 278 |
+
transform: `translateY(${(1 - drop) * -120}px) rotate(${(1 - drop) * -6}deg)`,
|
| 279 |
+
}}
|
| 280 |
+
>
|
| 281 |
+
<div style={{ width: 54, height: 66, borderRadius: 8, border: `2px solid ${BLUE}`, display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 18, color: BLUE }}>
|
| 282 |
+
.md
|
| 283 |
+
</div>
|
| 284 |
+
<div style={{ display: "grid", gap: 4 }}>
|
| 285 |
+
<span style={{ fontFamily: theme.body, fontSize: 30, color: theme.white }}>notes.md, essays.md, drafts.md</span>
|
| 286 |
+
<span style={{ fontFamily: theme.mono, fontSize: 20, color: "rgba(255,255,255,0.6)" }}>No feed? Drop in markdown, text, HTML or PDF</span>
|
| 287 |
</div>
|
| 288 |
</div>
|
| 289 |
</AbsoluteFill>
|
|
|
|
| 567 |
<div style={{ width: 44, height: 44, borderRadius: "50%", background: theme.black, boxShadow: `18px -18px 0 -10px ${theme.black}` }} />
|
| 568 |
</div>
|
| 569 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 120, color: theme.white, opacity: s, textShadow: `0 0 40px ${BLUE}` }}>Notestack</h1>
|
| 570 |
+
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 40, color: "rgba(255,255,255,0.8)", opacity: cta }}>Your knowledge, in orbit.</p>
|
| 571 |
<p style={{ margin: 0, padding: "18px 40px", borderRadius: 999, background: BLUE, color: theme.white, fontFamily: theme.body, fontWeight: 600, fontSize: 32, opacity: cta, boxShadow: `0 0 40px ${BLUE}` }}>
|
| 572 |
Free during early access
|
| 573 |
</p>
|