diff --git a/.changeset/config.json b/.changeset/config.json index d34de4d36..ccd678c7c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "restricted", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": ["web", "@example/*"] + "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["web", "@example/*"] } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 58dbfb053..61c8377db 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,3 @@ { - "recommendations": [ - "biomejs.biome", - "unifiedjs.vscode-mdx" - ] -} \ No newline at end of file + "recommendations": ["biomejs.biome", "unifiedjs.vscode-mdx"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index ad4c66ce6..14ad3aa19 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,28 +1,28 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "editor.defaultFormatter": "esbenp.prettier-vscode", - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[yaml]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "files.associations": { - "*.mdx": "markdown" - }, - "[svelte]": { - "editor.defaultFormatter": "svelte.svelte-vscode" - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "typescript.tsdk": "node_modules/typescript/lib", + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.associations": { + "*.mdx": "markdown" + }, + "[svelte]": { + "editor.defaultFormatter": "svelte.svelte-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/apps/relay/biome.json b/apps/relay/biome.json deleted file mode 100644 index 75d42e051..000000000 --- a/apps/relay/biome.json +++ /dev/null @@ -1 +0,0 @@ -{ "extends": ["@evolu/biome-config"] } diff --git a/apps/relay/src/index.ts b/apps/relay/src/index.ts index 5baaf2678..746151199 100644 --- a/apps/relay/src/index.ts +++ b/apps/relay/src/index.ts @@ -8,27 +8,27 @@ mkdirSync("data", { recursive: true }); process.chdir("data"); const deps = { - console: createConsole(), + console: createConsole(), }; const relay = await createNodeJsRelay(deps)({ - port: 4000, - enableLogging: false, + port: 4000, + enableLogging: false, - // Note: Relay requires URL in format ws://host:port/ - // isOwnerAllowed: (_ownerId) => true, + // Note: Relay requires URL in format ws://host:port/ + // isOwnerAllowed: (_ownerId) => true, - isOwnerWithinQuota: (_ownerId, requiredBytes) => { - const maxBytes = 1024 * 1024; // 1MB - return requiredBytes <= maxBytes; - }, + isOwnerWithinQuota: (_ownerId, requiredBytes) => { + const maxBytes = 1024 * 1024; // 1MB + return requiredBytes <= maxBytes; + }, }); if (!relay.ok) { - deps.console.error(relay.error); + deps.console.error(relay.error); } else { - // The `using` declaration ensures `relay.value[Symbol.dispose]()` is called - // automatically when the block exits. - using _ = relay.value; - await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); + // The `using` declaration ensures `relay.value[Symbol.dispose]()` is called + // automatically when the block exits. + using _ = relay.value; + await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } diff --git a/apps/web/biome.json b/apps/web/biome.json deleted file mode 100644 index cc2182559..000000000 --- a/apps/web/biome.json +++ /dev/null @@ -1 +0,0 @@ -{"extends": ["@evolu/biome-config"]} diff --git a/apps/web/mdx-components.tsx b/apps/web/mdx-components.tsx index b8a4e702f..b75dd0478 100644 --- a/apps/web/mdx-components.tsx +++ b/apps/web/mdx-components.tsx @@ -4,8 +4,8 @@ import * as mdxComponents from "@/components/mdx"; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function useMDXComponents(components: MDXComponents) { - return { - ...components, - ...mdxComponents, - }; + return { + ...components, + ...mdxComponents, + }; } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 0c578d231..09d83389c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -6,48 +6,48 @@ import { remarkPlugins } from "./src/mdx/remark.mjs"; import withSearch from "./src/mdx/search.mjs"; const withMDX = nextMDX({ - options: { - remarkPlugins, - rehypePlugins, - recmaPlugins, - }, + options: { + remarkPlugins, + rehypePlugins, + recmaPlugins, + }, }); /** @type {import("next").NextConfig} */ const nextConfig = { - pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"], - outputFileTracingIncludes: { - "/**/*": ["./src/app/**/*.mdx"], - }, - async redirects() { - return [ - { - source: "/docs/quickstart", - destination: "/docs/local-first", - permanent: true, - }, - { - source: "/docs/installation", - destination: "/docs/local-first", - permanent: true, - }, - { - source: "/docs/evolu-server", - destination: "/docs/relay", - permanent: true, - }, - { - source: "/docs/evolu-relay", - destination: "/docs/relay", - permanent: true, - }, - { - source: "/examples/:path*", - destination: "/docs/examples", - permanent: true, - }, - ]; - }, + pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"], + outputFileTracingIncludes: { + "/**/*": ["./src/app/**/*.mdx"], + }, + async redirects() { + return [ + { + source: "/docs/quickstart", + destination: "/docs/local-first", + permanent: true, + }, + { + source: "/docs/installation", + destination: "/docs/local-first", + permanent: true, + }, + { + source: "/docs/evolu-server", + destination: "/docs/relay", + permanent: true, + }, + { + source: "/docs/evolu-relay", + destination: "/docs/relay", + permanent: true, + }, + { + source: "/examples/:path*", + destination: "/docs/examples", + permanent: true, + }, + ]; + }, }; export default withSearch(withMDX(nextConfig)); diff --git a/apps/web/package.json b/apps/web/package.json index b9568629e..82605aeb1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,65 +1,65 @@ { - "name": "web", - "version": "2.0.0", - "private": true, - "scripts": { - "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 next build --webpack", - "clean": "rimraf .turbo .next node_modules", - "dev": "next dev --webpack", - "fix:docs": "bun ./scripts/fix-api-reference.mts", - "lint": "biome check .", - "start": "next start" - }, - "browserslist": "defaults, not ie <= 11", - "dependencies": { - "@algolia/autocomplete-core": "^1.19.2", - "@evolu/common": "workspace:*", - "@evolu/react": "workspace:*", - "@evolu/react-web": "workspace:*", - "@headlessui/react": "^2.2.7", - "@headlessui/tailwindcss": "^0.2.2", - "@mdx-js/loader": "^3.1.0", - "@mdx-js/react": "^3.1.0", - "@next/mdx": "^16.1.1", - "@sindresorhus/slugify": "^3.0.0", - "@tabler/icons-react": "^3.35.0", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/postcss": "^4.1.18", - "@tailwindcss/typography": "^0.5.16", - "acorn": "^8.15.0", - "clsx": "^2.1.1", - "fast-glob": "^3.3.3", - "flexsearch": "^0.8.205", - "mdast-util-to-string": "^4.0.0", - "mdx-annotations": "^0.1.4", - "motion": "^12.23.26", - "next": "^16.1.1", - "next-themes": "^0.4.6", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-highlight-words": "^0.21.0", - "remark": "^15.0.1", - "remark-gfm": "^4.0.1", - "remark-mdx": "^3.1.0", - "rss": "^1.2.2", - "shiki": "^3.19.0", - "simple-functional-loader": "^1.2.1", - "tailwindcss": "^4.1.18", - "typescript": "^5.9.3", - "unist-util-filter": "^5.0.1", - "unist-util-visit": "^5.0.0", - "zustand": "^5.0.9" - }, - "devDependencies": { - "@evolu/biome-config": "workspace:*", - "@evolu/tsconfig": "workspace:*", - "@types/mdx": "^2.0.13", - "@types/node": "^24.10.3", - "@types/react": "^19.1.17", - "@types/react-dom": "^19.1.11", - "@types/react-highlight-words": "^0.20.1", - "@types/rss": "^0.0.32", - "cross-env": "^10.0.0", - "sharp": "^0.34.3" - } + "name": "web", + "version": "2.0.0", + "private": true, + "scripts": { + "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 next build --webpack", + "clean": "rimraf .turbo .next node_modules", + "dev": "next dev --webpack", + "fix:docs": "bun ./scripts/fix-api-reference.mts", + "lint": "biome check .", + "start": "next start" + }, + "browserslist": "defaults, not ie <= 11", + "dependencies": { + "@algolia/autocomplete-core": "^1.19.2", + "@evolu/common": "workspace:*", + "@evolu/react": "workspace:*", + "@evolu/react-web": "workspace:*", + "@headlessui/react": "^2.2.7", + "@headlessui/tailwindcss": "^0.2.2", + "@mdx-js/loader": "^3.1.0", + "@mdx-js/react": "^3.1.0", + "@next/mdx": "^16.1.1", + "@sindresorhus/slugify": "^3.0.0", + "@tabler/icons-react": "^3.35.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/typography": "^0.5.16", + "acorn": "^8.15.0", + "clsx": "^2.1.1", + "fast-glob": "^3.3.3", + "flexsearch": "^0.8.205", + "mdast-util-to-string": "^4.0.0", + "mdx-annotations": "^0.1.4", + "motion": "^12.23.26", + "next": "^16.1.1", + "next-themes": "^0.4.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-highlight-words": "^0.21.0", + "remark": "^15.0.1", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.0", + "rss": "^1.2.2", + "shiki": "^3.19.0", + "simple-functional-loader": "^1.2.1", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "unist-util-filter": "^5.0.1", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@evolu/biome-config": "workspace:*", + "@evolu/tsconfig": "workspace:*", + "@types/mdx": "^2.0.13", + "@types/node": "^24.10.3", + "@types/react": "^19.1.17", + "@types/react-dom": "^19.1.11", + "@types/react-highlight-words": "^0.20.1", + "@types/rss": "^0.0.32", + "cross-env": "^10.0.0", + "sharp": "^0.34.3" + } } diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js index 06f0db34f..37f8550d0 100644 --- a/apps/web/postcss.config.js +++ b/apps/web/postcss.config.js @@ -1,6 +1,6 @@ // eslint-disable-next-line no-undef module.exports = { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + "@tailwindcss/postcss": {}, + }, }; diff --git a/apps/web/scripts/fix-api-reference.mts b/apps/web/scripts/fix-api-reference.mts index 75e92c3e8..8bf739e4e 100644 --- a/apps/web/scripts/fix-api-reference.mts +++ b/apps/web/scripts/fix-api-reference.mts @@ -3,99 +3,99 @@ import fs from "node:fs"; import path from "node:path"; const reference = path.join( - import.meta.dirname, - "..", - "src/app/(docs)/docs/api-reference", + import.meta.dirname, + "..", + "src/app/(docs)/docs/api-reference", ); const rearrangeMdxFilesRecursively = (dir: string): void => { - for (const item of fs.readdirSync(dir)) { - const fullPath = path.join(dir, item); - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - rearrangeMdxFilesRecursively(fullPath); - } else if (item.endsWith(".mdx")) { - if (item !== "page.mdx") { - const baseName = path.basename(item, ".mdx"); - const newFolder = path.join(dir, baseName); - fs.mkdirSync(newFolder, { recursive: true }); - fs.renameSync(fullPath, path.join(newFolder, "page.mdx")); - fixMdxFile( - path.join(newFolder, "page.mdx"), - `${baseName} - API reference`, - ); - } else { - const title = - dir === reference - ? "API reference" - : `${path.basename(dir)} - API reference`; - fixMdxFile(fullPath, title); - } - } - } + for (const item of fs.readdirSync(dir)) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + rearrangeMdxFilesRecursively(fullPath); + } else if (item.endsWith(".mdx")) { + if (item !== "page.mdx") { + const baseName = path.basename(item, ".mdx"); + const newFolder = path.join(dir, baseName); + fs.mkdirSync(newFolder, { recursive: true }); + fs.renameSync(fullPath, path.join(newFolder, "page.mdx")); + fixMdxFile( + path.join(newFolder, "page.mdx"), + `${baseName} - API reference`, + ); + } else { + const title = + dir === reference + ? "API reference" + : `${path.basename(dir)} - API reference`; + fixMdxFile(fullPath, title); + } + } + } }; const fixMdxFile = (filePath: string, title: string): void => { - const content = fs.readFileSync(filePath, "utf-8"); - // first let's replace /page.mdx with / - let newContent = content.replace(/\/page\.mdx/g, ""); - // Remove .mdx from Markdown link destinations, preserving query/hash. - // Examples: - // - [X](/docs/Foo.mdx) -> [X](/docs/Foo) - // - [X](/docs/Foo.mdx#bar) -> [X](/docs/Foo#bar) - // - [X](../Foo.mdx?x=1#bar) -> [X](../Foo?x=1#bar) - newContent = newContent.replace(/\]\(([^)]*?)\.mdx(?=[)#?])/g, "]($1"); + const content = fs.readFileSync(filePath, "utf-8"); + // first let's replace /page.mdx with / + let newContent = content.replace(/\/page\.mdx/g, ""); + // Remove .mdx from Markdown link destinations, preserving query/hash. + // Examples: + // - [X](/docs/Foo.mdx) -> [X](/docs/Foo) + // - [X](/docs/Foo.mdx#bar) -> [X](/docs/Foo#bar) + // - [X](../Foo.mdx?x=1#bar) -> [X](../Foo?x=1#bar) + newContent = newContent.replace(/\]\(([^)]*?)\.mdx(?=[)#?])/g, "]($1"); - // fix API reference breadcrumb link and separator - // Breadcrumb is the first line starting with `[API` - replace link text and separators - newContent = newContent.replace( - /^(\[API Reference\]\([^)]*\))(.*)/m, - (_match, _apiLink, rest: string) => { - const fixedRest = rest.replace(/ \/ /g, " › "); - return `[API reference](/docs/api-reference)${fixedRest}`; - }, - ); + // fix API reference breadcrumb link and separator + // Breadcrumb is the first line starting with `[API` - replace link text and separators + newContent = newContent.replace( + /^(\[API Reference\]\([^)]*\))(.*)/m, + (_match, _apiLink, rest: string) => { + const fixedRest = rest.replace(/ \/ /g, " › "); + return `[API reference](/docs/api-reference)${fixedRest}`; + }, + ); - // Remove redundant sections (heading + content until next heading of same or higher level) - const lines = newContent.split("\n"); - const result: Array = []; - let skipUntilLevel = 0; // 0 = not skipping, otherwise skip until heading with <= this many # + // Remove redundant sections (heading + content until next heading of same or higher level) + const lines = newContent.split("\n"); + const result: Array = []; + let skipUntilLevel = 0; // 0 = not skipping, otherwise skip until heading with <= this many # - for (const line of lines) { - const headingMatch = /^(#{2,4}) /.exec(line); - if (headingMatch) { - const level = headingMatch[1].length; - if ( - line.startsWith("## Type Parameter") || - line.startsWith("## Parameter") || - line.startsWith("## Return") || - line.startsWith("### Type Parameter") || - line.startsWith("### Parameter") || - line.startsWith("### Return") || - line.startsWith("#### Type Parameter") || - line.startsWith("#### Parameter") || - line.startsWith("#### Return") - ) { - skipUntilLevel = level; - continue; - } - if (skipUntilLevel > 0 && level <= skipUntilLevel) { - skipUntilLevel = 0; - } - } - if (skipUntilLevel === 0) result.push(line); - } - newContent = result.join("\n"); + for (const line of lines) { + const headingMatch = /^(#{2,4}) /.exec(line); + if (headingMatch) { + const level = headingMatch[1].length; + if ( + line.startsWith("## Type Parameter") || + line.startsWith("## Parameter") || + line.startsWith("## Return") || + line.startsWith("### Type Parameter") || + line.startsWith("### Parameter") || + line.startsWith("### Return") || + line.startsWith("#### Type Parameter") || + line.startsWith("#### Parameter") || + line.startsWith("#### Return") + ) { + skipUntilLevel = level; + continue; + } + if (skipUntilLevel > 0 && level <= skipUntilLevel) { + skipUntilLevel = 0; + } + } + if (skipUntilLevel === 0) result.push(line); + } + newContent = result.join("\n"); - newContent = newContent - .replace(/^export const metadata = \{ title: [^}]*\};\s*\r?\n\s*/, "") - .replace(/^export const sections = .*;\s*\r?\n\s*/m, ""); + newContent = newContent + .replace(/^export const metadata = \{ title: [^}]*\};\s*\r?\n\s*/, "") + .replace(/^export const sections = .*;\s*\r?\n\s*/m, ""); - newContent = `export const metadata = { title: '${title}' }; + newContent = `export const metadata = { title: '${title}' }; ${newContent}`; - fs.writeFileSync(filePath, newContent); + fs.writeFileSync(filePath, newContent); }; // Run the script diff --git a/apps/web/scripts/tsconfig.json b/apps/web/scripts/tsconfig.json index 0e25cbfd6..0b7be643a 100644 --- a/apps/web/scripts/tsconfig.json +++ b/apps/web/scripts/tsconfig.json @@ -3,9 +3,7 @@ "display": "Node 22", "_version": "22.0.0", "compilerOptions": { - "lib": [ - "es2023" - ], + "lib": ["es2023"], "module": "node16", "target": "es2022", "strict": true, @@ -13,4 +11,4 @@ "skipLibCheck": true, "moduleResolution": "node16" } -} \ No newline at end of file +} diff --git a/apps/web/src/app/(docs)/layout.tsx b/apps/web/src/app/(docs)/layout.tsx index 8e1271ca5..f8b6e5233 100644 --- a/apps/web/src/app/(docs)/layout.tsx +++ b/apps/web/src/app/(docs)/layout.tsx @@ -8,30 +8,32 @@ import { type Section } from "@/components/SectionProvider"; import "@/styles/tailwind.css"; export const metadata: Metadata = { - title: { - template: "%s - Evolu", - default: "TypeScript library and local-first platform", - }, + title: { + template: "%s - Evolu", + default: "TypeScript library and local-first platform", + }, }; export default async function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): Promise { - const pages = await glob("**/*.mdx", { cwd: "src/app/(docs)" }); - const allSectionsEntries = (await Promise.all( - pages.map(async (filename) => [ - "/" + filename.replace(/(^|\/)page\.mdx$/, ""), - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (await import(`./${filename}`)).sections, - ]), - )) as Array<[string, Array
]>; - const allSections = Object.fromEntries(allSectionsEntries); + const pages = await glob("**/*.mdx", { cwd: "src/app/(docs)" }); + const allSectionsEntries = (await Promise.all( + pages.map(async (filename) => [ + "/" + filename.replace(/(^|\/)page\.mdx$/, ""), + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ( + await import(`./${filename}`) + ).sections, + ]), + )) as Array<[string, Array
]>; + const allSections = Object.fromEntries(allSectionsEntries); - return ( - - {children} - - ); + return ( + + {children} + + ); } diff --git a/apps/web/src/app/(landing)/blog/page.tsx b/apps/web/src/app/(landing)/blog/page.tsx index 690a19b1e..1ff3991ef 100644 --- a/apps/web/src/app/(landing)/blog/page.tsx +++ b/apps/web/src/app/(landing)/blog/page.tsx @@ -8,61 +8,61 @@ import { type ArticleWithSlug, getAllArticles } from "@/lib/blog"; import { formatDate } from "@/lib/formatDate"; function Article({ article }: { article: ArticleWithSlug }) { - return ( -
- - {article.title} - - {formatDate(article.date)} - - {article.description} - Read article - - - {formatDate(article.date)} - -
- ); + return ( +
+ + {article.title} + + {formatDate(article.date)} + + {article.description} + Read article + + + {formatDate(article.date)} + +
+ ); } export const metadata: Metadata = { - title: "Blog", - description: "Restore data ownership", + title: "Blog", + description: "Restore data ownership", }; export default async function ArticlesIndex(): Promise { - const articles = await getAllArticles(); + const articles = await getAllArticles(); - return ( - -
-
- {articles.map((article) => ( -
- ))} -
-
-
-
- - - RSS Feed - -
-
-
- ); + return ( + +
+
+ {articles.map((article) => ( +
+ ))} +
+
+
+
+ + + RSS Feed + +
+
+
+ ); } diff --git a/apps/web/src/app/(landing)/blog/rss.xml/route.ts b/apps/web/src/app/(landing)/blog/rss.xml/route.ts index c2dd0be6f..46badc454 100644 --- a/apps/web/src/app/(landing)/blog/rss.xml/route.ts +++ b/apps/web/src/app/(landing)/blog/rss.xml/route.ts @@ -2,47 +2,47 @@ import { type ArticleWithSlug, getAllArticles } from "@/lib/blog"; import RSS from "rss"; function getSiteUrl(request: Request): string { - if (process.env.NODE_ENV === "production") { - return "https://www.evolu.dev"; - } + if (process.env.NODE_ENV === "production") { + return "https://www.evolu.dev"; + } - const url = new URL(request.url); - return `${url.protocol}//${url.host}`; + const url = new URL(request.url); + return `${url.protocol}//${url.host}`; } export async function GET(request: Request): Promise { - const articles = await getAllArticles(); - const currentSiteUrl = getSiteUrl(request); + const articles = await getAllArticles(); + const currentSiteUrl = getSiteUrl(request); - const feed = new RSS({ - title: "Evolu Blog", - description: "Restore data ownership", - feed_url: `${currentSiteUrl}/blog/rss.xml`, - site_url: currentSiteUrl, - language: "en", - pubDate: new Date().toISOString(), - copyright: `© ${new Date().getFullYear()} Evolu`, - docs: "https://validator.w3.org/feed/docs/rss2.html", - ttl: 60, - }); + const feed = new RSS({ + title: "Evolu Blog", + description: "Restore data ownership", + feed_url: `${currentSiteUrl}/blog/rss.xml`, + site_url: currentSiteUrl, + language: "en", + pubDate: new Date().toISOString(), + copyright: `© ${new Date().getFullYear()} Evolu`, + docs: "https://validator.w3.org/feed/docs/rss2.html", + ttl: 60, + }); - articles.forEach((article: ArticleWithSlug) => { - feed.item({ - title: article.title, - description: article.description, - url: `${currentSiteUrl}/blog/${article.slug}`, - guid: `${currentSiteUrl}/blog/${article.slug}`, - date: new Date(article.date), - author: article.author, - }); - }); + articles.forEach((article: ArticleWithSlug) => { + feed.item({ + title: article.title, + description: article.description, + url: `${currentSiteUrl}/blog/${article.slug}`, + guid: `${currentSiteUrl}/blog/${article.slug}`, + date: new Date(article.date), + author: article.author, + }); + }); - return new Response(feed.xml(), { - headers: { - "Content-Type": "application/rss+xml; charset=utf-8", - "Cache-Control": "public, max-age=3600, s-maxage=3600", - }, - }); + return new Response(feed.xml(), { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }); } export const dynamic = "force-static"; diff --git a/apps/web/src/app/(landing)/layout.tsx b/apps/web/src/app/(landing)/layout.tsx index 6474b2921..b418a677d 100644 --- a/apps/web/src/app/(landing)/layout.tsx +++ b/apps/web/src/app/(landing)/layout.tsx @@ -6,27 +6,27 @@ import { Header } from "@/components/Header"; import "@/styles/tailwind.css"; export const metadata: Metadata = { - title: { - template: "%s - Evolu", - default: "TypeScript library and local-first platform", - }, + title: { + template: "%s - Evolu", + default: "TypeScript library and local-first platform", + }, }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): React.ReactElement { - return ( - -
-
+ return ( + +
+
-
-
{children}
-
-
-
-
- ); +
+
{children}
+
+
+
+
+ ); } diff --git a/apps/web/src/app/(landing)/page.tsx b/apps/web/src/app/(landing)/page.tsx index b77d22ebd..0c3e73cf2 100644 --- a/apps/web/src/app/(landing)/page.tsx +++ b/apps/web/src/app/(landing)/page.tsx @@ -4,42 +4,42 @@ import { Logo } from "@/components/Logo"; import { Metadata } from "next"; export const metadata: Metadata = { - title: "Evolu", - description: "TypeScript library and local-first platform", + title: "Evolu", + description: "TypeScript library and local-first platform", }; export default function Page(): React.ReactElement { - return ( - <> -
- -

- TypeScript library and local‑first platform -

-
- -
- -

- Own your apps and data. -
- Work offline, sync online. -
- No vendor lock‑in. - * -

-

- *Of course, SQLite and Evolu are kind of lock‑in, but - replaceable because SQL is standard, and Evolu is just a thin layer on - standard APIs. -

-
- - ); + return ( + <> +
+ +

+ TypeScript library and local‑first platform +

+
+ +
+ +

+ Own your apps and data. +
+ Work offline, sync online. +
+ No vendor lock‑in. + * +

+

+ *Of course, SQLite and Evolu are kind of lock‑in, but + replaceable because SQL is standard, and Evolu is just a thin layer on + standard APIs. +

+
+ + ); } diff --git a/apps/web/src/app/(llms)/layout.tsx b/apps/web/src/app/(llms)/layout.tsx index 1ee5100f8..ab3be3e95 100644 --- a/apps/web/src/app/(llms)/layout.tsx +++ b/apps/web/src/app/(llms)/layout.tsx @@ -1,11 +1,11 @@ export default function LLMsLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): React.ReactNode { - return ( -
-      {children}
-    
- ); + return ( +
+			{children}
+		
+ ); } diff --git a/apps/web/src/app/(llms)/llms-full.txt/page.tsx b/apps/web/src/app/(llms)/llms-full.txt/page.tsx index 0088d0b24..1669f50d1 100644 --- a/apps/web/src/app/(llms)/llms-full.txt/page.tsx +++ b/apps/web/src/app/(llms)/llms-full.txt/page.tsx @@ -2,24 +2,24 @@ import { Fragment } from "react"; import { fetchProcessedMdxPages } from "../../../lib/llms"; export default async function LLMsFullPage(): Promise { - const pages = await fetchProcessedMdxPages(true); // Pass true to include API reference + const pages = await fetchProcessedMdxPages(true); // Pass true to include API reference - return ( - <> -
-

Evolu Documentation

-
- {pages.map((page, index) => ( - -
-

{page.title}

-
{page.content}
-
-
-
- ))} -
-
- - ); + return ( + <> +
+

Evolu Documentation

+
+ {pages.map((page, index) => ( + +
+

{page.title}

+
{page.content}
+
+
+
+ ))} +
+
+ + ); } diff --git a/apps/web/src/app/(llms)/llms.txt/page.tsx b/apps/web/src/app/(llms)/llms.txt/page.tsx index 647b6140d..fae46166d 100644 --- a/apps/web/src/app/(llms)/llms.txt/page.tsx +++ b/apps/web/src/app/(llms)/llms.txt/page.tsx @@ -2,18 +2,18 @@ import { Fragment } from "react"; import { fetchProcessedMdxPages } from "../../../lib/llms"; export default async function LLMsPage(): Promise { - const pages = await fetchProcessedMdxPages(false); + const pages = await fetchProcessedMdxPages(false); - return ( - <> - # Evolu Documentation -
-
- {pages.map((page, index) => ( - - {page.content}

-
- ))} - - ); + return ( + <> + # Evolu Documentation +
+
+ {pages.map((page, index) => ( + + {page.content}

+
+ ))} + + ); } diff --git a/apps/web/src/app/(playgrounds)/layout.tsx b/apps/web/src/app/(playgrounds)/layout.tsx index 5d4dde023..8b4f99f4f 100644 --- a/apps/web/src/app/(playgrounds)/layout.tsx +++ b/apps/web/src/app/(playgrounds)/layout.tsx @@ -2,9 +2,9 @@ import "@/styles/tailwind.css"; // import "@tailwindcss/forms"; export default function PlaygroundLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): React.ReactNode { - return children; + return children; } diff --git a/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx index 5fdef7e6b..f4d5725ed 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx @@ -1,53 +1,53 @@ "use client"; import { - booleanToSqliteBoolean, - createEvolu, - createFormatTypeError, - createObjectURL, - FiniteNumber, - id, - idToIdBytes, - json, - kysely, - maxLength, - MaxLengthError, - MinLengthError, - Mnemonic, - NonEmptyString, - NonEmptyTrimmedString100, - nullOr, - object, - SimpleName, - SqliteBoolean, - sqliteFalse, - sqliteTrue, - timestampBytesToTimestamp, + booleanToSqliteBoolean, + createEvolu, + createFormatTypeError, + createObjectURL, + FiniteNumber, + id, + idToIdBytes, + json, + kysely, + maxLength, + MaxLengthError, + MinLengthError, + Mnemonic, + NonEmptyString, + NonEmptyTrimmedString100, + nullOr, + object, + SimpleName, + SqliteBoolean, + sqliteFalse, + sqliteTrue, + timestampBytesToTimestamp, } from "@evolu/common"; import { timestampToDateIso } from "@evolu/common/local-first"; import { - createUseEvolu, - EvoluProvider, - useQueries, - useQuery, + createUseEvolu, + EvoluProvider, + useQueries, + useQuery, } from "@evolu/react"; import { createEvoluDeps } from "@evolu/react-web"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { - IconChecklist, - IconEdit, - IconHistory, - IconRestore, - IconTrash, + IconChecklist, + IconEdit, + IconHistory, + IconRestore, + IconTrash, } from "@tabler/icons-react"; import clsx from "clsx"; import { - FC, - KeyboardEvent, - startTransition, - Suspense, - use, - useState, + FC, + KeyboardEvent, + startTransition, + Suspense, + use, + useState, } from "react"; // TODO: Epochs and sharing. @@ -68,10 +68,10 @@ type NonEmptyString50 = typeof NonEmptyString50.Type; // or when the schema varies by use case. // Let's create an object to demonstrate it. const Foo = object({ - foo: NonEmptyString50, - // Did you know that JSON.stringify converts NaN (a number) into null? - // To prevent this, use FiniteNumber. - bar: FiniteNumber, + foo: NonEmptyString50, + // Did you know that JSON.stringify converts NaN (a number) into null? + // To prevent this, use FiniteNumber. + bar: FiniteNumber, }); type Foo = typeof Foo.Type; @@ -82,843 +82,843 @@ const [FooJson, fooToFooJson, fooJsonToFoo] = json(Foo, "FooJson"); type FooJson = typeof FooJson.Type; const Schema = { - project: { - id: ProjectId, - name: NonEmptyTrimmedString100, - fooJson: FooJson, - }, - todo: { - id: TodoId, - title: NonEmptyTrimmedString100, - isCompleted: nullOr(SqliteBoolean), - projectId: nullOr(ProjectId), - }, + project: { + id: ProjectId, + name: NonEmptyTrimmedString100, + fooJson: FooJson, + }, + todo: { + id: TodoId, + title: NonEmptyTrimmedString100, + isCompleted: nullOr(SqliteBoolean), + projectId: nullOr(ProjectId), + }, }; const deps = createEvoluDeps(); const evolu = createEvolu(deps)(Schema, { - name: SimpleName.orThrow("full-example"), + name: SimpleName.orThrow("full-example"), - // reloadUrl: "/playgrounds/full", + // reloadUrl: "/playgrounds/full", - ...(process.env.NODE_ENV === "development" && { - transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], + ...(process.env.NODE_ENV === "development" && { + transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], - // Empty transports for local-only instance. - // transports: [], - }), + // Empty transports for local-only instance. + // transports: [], + }), - // https://www.evolu.dev/docs/indexes - indexes: (create) => [ - create("todoCreatedAt").on("todo").column("createdAt"), - create("projectCreatedAt").on("project").column("createdAt"), - create("todoProjectId").on("todo").column("projectId"), - ], + // https://www.evolu.dev/docs/indexes + indexes: (create) => [ + create("todoCreatedAt").on("todo").column("createdAt"), + create("projectCreatedAt").on("project").column("createdAt"), + create("todoProjectId").on("todo").column("projectId"), + ], - // enableLogging: false, + // enableLogging: false, }); const useEvolu = createUseEvolu(evolu); evolu.subscribeError(() => { - const error = evolu.getError(); - if (!error) return; + const error = evolu.getError(); + if (!error) return; - alert("🚨 Evolu error occurred! Check the console."); - // eslint-disable-next-line no-console - console.error(error); + alert("🚨 Evolu error occurred! Check the console."); + // eslint-disable-next-line no-console + console.error(error); }); export const EvoluFullExample: FC = () => { - return ( -
-
- - - - - -
-
- ); + return ( +
+
+ + + + + +
+
+ ); }; const App: FC = () => { - const [activeTab, setActiveTab] = useState< - "home" | "projects" | "account" | "trash" - >("home"); - - const createHandleTabClick = (tab: typeof activeTab) => () => { - // startTransition prevents UI flickers when switching tabs by keeping - // the current view visible while Suspense prepares the next one - // Test: Remove startTransition, add a todo, delete it, click to Trash. - // You will see a visible blink without startTransition. - startTransition(() => { - setActiveTab(tab); - }); - }; - - return ( -
-
-
- - - - -
-
- - {activeTab === "home" && } - {activeTab === "projects" && } - {activeTab === "account" && } - {activeTab === "trash" && } -
- ); + const [activeTab, setActiveTab] = useState< + "home" | "projects" | "account" | "trash" + >("home"); + + const createHandleTabClick = (tab: typeof activeTab) => () => { + // startTransition prevents UI flickers when switching tabs by keeping + // the current view visible while Suspense prepares the next one + // Test: Remove startTransition, add a todo, delete it, click to Trash. + // You will see a visible blink without startTransition. + startTransition(() => { + setActiveTab(tab); + }); + }; + + return ( +
+
+
+ + + + +
+
+ + {activeTab === "home" && } + {activeTab === "projects" && } + {activeTab === "account" && } + {activeTab === "trash" && } +
+ ); }; const projectsWithTodosQuery = evolu.createQuery( - (db) => - db - .selectFrom("project") - .select(["id", "name"]) - // https://kysely.dev/docs/recipes/relations - .select((eb) => [ - kysely - .jsonArrayFrom( - eb - .selectFrom("todo") - .select([ - "todo.id", - "todo.title", - "todo.isCompleted", - "todo.projectId", - ]) - .whereRef("todo.projectId", "=", "project.id") - .where("todo.isDeleted", "is not", sqliteTrue) - .where("todo.title", "is not", null) - .$narrowType<{ title: kysely.NotNull }>() - .orderBy("createdAt"), - ) - .as("todos"), - ]) - .where("project.isDeleted", "is not", sqliteTrue) - .where("name", "is not", null) - .$narrowType<{ name: kysely.NotNull }>() - .orderBy("createdAt"), - { - // Log how long each query execution takes - logQueryExecutionTime: false, - - // Log the SQLite query execution plan for optimization analysis - logExplainQueryPlan: false, - }, + (db) => + db + .selectFrom("project") + .select(["id", "name"]) + // https://kysely.dev/docs/recipes/relations + .select((eb) => [ + kysely + .jsonArrayFrom( + eb + .selectFrom("todo") + .select([ + "todo.id", + "todo.title", + "todo.isCompleted", + "todo.projectId", + ]) + .whereRef("todo.projectId", "=", "project.id") + .where("todo.isDeleted", "is not", sqliteTrue) + .where("todo.title", "is not", null) + .$narrowType<{ title: kysely.NotNull }>() + .orderBy("createdAt"), + ) + .as("todos"), + ]) + .where("project.isDeleted", "is not", sqliteTrue) + .where("name", "is not", null) + .$narrowType<{ name: kysely.NotNull }>() + .orderBy("createdAt"), + { + // Log how long each query execution takes + logQueryExecutionTime: false, + + // Log the SQLite query execution plan for optimization analysis + logExplainQueryPlan: false, + }, ); type ProjectsWithTodosRow = typeof projectsWithTodosQuery.Row; const HomeTab: FC = () => { - const [projectsWithTodos, projects] = useQueries([ - projectsWithTodosQuery, - /** - * Load projects separately for better cache efficiency. Projects change - * less frequently than todos, preventing unnecessary re-renders. Multiple - * queries are fine in local-first - no network overhead. - */ - projectsQuery, - ]); - - const handleAddProjectClick = useAddProject(); - - if (projectsWithTodos.length === 0) { - return ( -
-
- -
-

- No projects yet -

-

- Create your first project to get started -

-
- ); - } - - return ( -
-
- {projectsWithTodos.map((project) => ( - - ))} -
-
- ); + const [projectsWithTodos, projects] = useQueries([ + projectsWithTodosQuery, + /** + * Load projects separately for better cache efficiency. Projects change + * less frequently than todos, preventing unnecessary re-renders. Multiple + * queries are fine in local-first - no network overhead. + */ + projectsQuery, + ]); + + const handleAddProjectClick = useAddProject(); + + if (projectsWithTodos.length === 0) { + return ( +
+
+ +
+

+ No projects yet +

+

+ Create your first project to get started +

+
+ ); + } + + return ( +
+
+ {projectsWithTodos.map((project) => ( + + ))} +
+
+ ); }; const HomeTabProject: FC<{ - project: ProjectsWithTodosRow; - todos: ProjectsWithTodosRow["todos"]; - projects: ReadonlyArray; + project: ProjectsWithTodosRow; + todos: ProjectsWithTodosRow["todos"]; + projects: ReadonlyArray; }> = ({ project, todos, projects }) => { - const { insert } = useEvolu(); - const [newTodoTitle, setNewTodoTitle] = useState(""); - - const addTodo = () => { - const result = insert( - "todo", - { - title: newTodoTitle.trim(), - projectId: project.id, - }, - { - onComplete: () => { - setNewTodoTitle(""); - }, - }, - ); - - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === "Enter") { - addTodo(); - } - }; - - return ( -
-
-

- - {project.name} -

-
- - {todos.length > 0 && ( -
    - {todos.map((todo) => ( - - ))} -
- )} - -
- { - setNewTodoTitle(e.target.value); - }} - data-1p-ignore // ignore this input from 1password, ugly hack but works - onKeyDown={handleKeyPress} - placeholder="Add a new todo..." - className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" - /> -
-
- ); + const { insert } = useEvolu(); + const [newTodoTitle, setNewTodoTitle] = useState(""); + + const addTodo = () => { + const result = insert( + "todo", + { + title: newTodoTitle.trim(), + projectId: project.id, + }, + { + onComplete: () => { + setNewTodoTitle(""); + }, + }, + ); + + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === "Enter") { + addTodo(); + } + }; + + return ( +
+
+

+ + {project.name} +

+
+ + {todos.length > 0 && ( +
    + {todos.map((todo) => ( + + ))} +
+ )} + +
+ { + setNewTodoTitle(e.target.value); + }} + data-1p-ignore // ignore this input from 1password, ugly hack but works + onKeyDown={handleKeyPress} + placeholder="Add a new todo..." + className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" + /> +
+
+ ); }; const HomeTabProjectSectionTodoItem: FC<{ - // [number] extracts the element type from the todos array - row: ProjectsWithTodosRow["todos"][number]; - projects: ReadonlyArray; + // [number] extracts the element type from the todos array + row: ProjectsWithTodosRow["todos"][number]; + projects: ReadonlyArray; }> = ({ row: { id, title, isCompleted, projectId }, projects }) => { - const { update } = useEvolu(); - - const handleToggleCompletedClick = () => { - // No need to check result if a mutation can't fail. - update("todo", { - id, - isCompleted: booleanToSqliteBoolean(!isCompleted), - }); - }; - - const handleProjectChange = (newProjectId: ProjectId) => { - update("todo", { id, projectId: newProjectId }); - }; - - const handleRenameClick = () => { - const newTitle = window.prompt("Edit todo", title); - if (newTitle == null) return; - - const result = update("todo", { id, title: newTitle.trim() }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleDeleteClick = () => { - update("todo", { id, isDeleted: sqliteTrue }); - }; - - // Demonstrate history tracking. Evolu automatically tracks all changes - // in the evolu_history table, making it easy to build audit logs or undo features. - const titleHistoryQuery = evolu.createQuery((db) => - db - .selectFrom("evolu_history") - .select(["value", "timestamp"]) - .where("table", "==", "todo") - .where("id", "==", idToIdBytes(id)) - .where("column", "==", "title") - // The value isn't typed; this is how we can cast it. - .$narrowType<{ value: (typeof Schema)["todo"]["title"]["Type"] }>() - .orderBy("timestamp", "desc"), - ); - - const handleHistoryClick = () => { - void evolu.loadQuery(titleHistoryQuery).then((rows) => { - const rowsWithTimestamp = rows.map((row) => ({ - value: row.value, - timestamp: timestampToDateIso(timestampBytesToTimestamp(row.timestamp)), - })); - alert(JSON.stringify(rowsWithTimestamp, null, 2)); - }); - }; - - return ( -
  • - -
    -
    - - - - - -
    - {projects.map((project) => ( - - - - ))} -
    -
    -
    - - - -
    -
    -
  • - ); + const { update } = useEvolu(); + + const handleToggleCompletedClick = () => { + // No need to check result if a mutation can't fail. + update("todo", { + id, + isCompleted: booleanToSqliteBoolean(!isCompleted), + }); + }; + + const handleProjectChange = (newProjectId: ProjectId) => { + update("todo", { id, projectId: newProjectId }); + }; + + const handleRenameClick = () => { + const newTitle = window.prompt("Edit todo", title); + if (newTitle == null) return; + + const result = update("todo", { id, title: newTitle.trim() }); + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + const handleDeleteClick = () => { + update("todo", { id, isDeleted: sqliteTrue }); + }; + + // Demonstrate history tracking. Evolu automatically tracks all changes + // in the evolu_history table, making it easy to build audit logs or undo features. + const titleHistoryQuery = evolu.createQuery((db) => + db + .selectFrom("evolu_history") + .select(["value", "timestamp"]) + .where("table", "==", "todo") + .where("id", "==", idToIdBytes(id)) + .where("column", "==", "title") + // The value isn't typed; this is how we can cast it. + .$narrowType<{ value: (typeof Schema)["todo"]["title"]["Type"] }>() + .orderBy("timestamp", "desc"), + ); + + const handleHistoryClick = () => { + void evolu.loadQuery(titleHistoryQuery).then((rows) => { + const rowsWithTimestamp = rows.map((row) => ({ + value: row.value, + timestamp: timestampToDateIso(timestampBytesToTimestamp(row.timestamp)), + })); + alert(JSON.stringify(rowsWithTimestamp, null, 2)); + }); + }; + + return ( +
  • + +
    +
    + + + + + +
    + {projects.map((project) => ( + + + + ))} +
    +
    +
    + + + +
    +
    +
  • + ); }; const projectsQuery = evolu.createQuery((db) => - db - .selectFrom("project") - .select(["id", "name", "fooJson"]) - .where("isDeleted", "is not", sqliteTrue) - .where("name", "is not", null) - .$narrowType<{ name: kysely.NotNull }>() - .where("fooJson", "is not", null) - .$narrowType<{ fooJson: kysely.NotNull }>() - .orderBy("createdAt"), + db + .selectFrom("project") + .select(["id", "name", "fooJson"]) + .where("isDeleted", "is not", sqliteTrue) + .where("name", "is not", null) + .$narrowType<{ name: kysely.NotNull }>() + .where("fooJson", "is not", null) + .$narrowType<{ fooJson: kysely.NotNull }>() + .orderBy("createdAt"), ); type ProjectsRow = typeof projectsQuery.Row; const useAddProject = () => { - const { insert } = useEvolu(); - - return () => { - const name = window.prompt("What's the project name?"); - if (name == null) return; - - // Demonstrate JSON usage. - const foo = Foo.from({ foo: "baz", bar: 42 }); - if (!foo.ok) return; - - const result = insert("project", { - name: name.trim(), - fooJson: fooToFooJson(foo.value), - }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; + const { insert } = useEvolu(); + + return () => { + const name = window.prompt("What's the project name?"); + if (name == null) return; + + // Demonstrate JSON usage. + const foo = Foo.from({ foo: "baz", bar: 42 }); + if (!foo.ok) return; + + const result = insert("project", { + name: name.trim(), + fooJson: fooToFooJson(foo.value), + }); + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; }; const ProjectsTab: FC = () => { - const projects = useQuery(projectsQuery); - const handleAddProjectClick = useAddProject(); - - return ( -
    -
    - {projects.map((project) => ( - - ))} -
    -
    -
    -
    - ); + const projects = useQuery(projectsQuery); + const handleAddProjectClick = useAddProject(); + + return ( +
    +
    + {projects.map((project) => ( + + ))} +
    +
    +
    +
    + ); }; const ProjectsTabProjectItem: FC<{ - project: ProjectsRow; + project: ProjectsRow; }> = ({ project }) => { - const { update } = useEvolu(); - - const handleRenameClick = () => { - const newName = window.prompt("Edit project name", project.name); - if (newName == null) return; - - const result = update("project", { id: project.id, name: newName.trim() }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleDeleteClick = () => { - if (confirm(`Are you sure you want to delete project "${project.name}"?`)) { - /** - * In a classic centralized client-server app, we would fetch all todos - * for this project and delete them too. But that approach is wrong for - * distributed eventually consistent systems for two reasons: - * - * 1. Sync overhead scales with todo count (a project with 10k todos would - * generate 10k sync messages instead of just 1 for the project) - * 2. It wouldn't delete todos from other devices before they sync - * - * The correct approach for local-first systems: handle cascading logic in - * the UI layer. Queries filter out deleted projects, so their todos - * naturally become hidden. If a todo detail view is needed, it should - * check whether its parent project was deleted. - */ - update("project", { - id: project.id, - isDeleted: sqliteTrue, - }); - } - }; - - // Demonstrate JSON deserialization. Because FooJson is a branded type, - // we can safely deserialize without validation - TypeScript guarantees - // only validated JSON strings can have the FooJson brand. - const _foo = fooJsonToFoo(project.fooJson); - - return ( -
    -
    - -
    -

    {project.name}

    -
    -
    -
    - - -
    -
    - ); + const { update } = useEvolu(); + + const handleRenameClick = () => { + const newName = window.prompt("Edit project name", project.name); + if (newName == null) return; + + const result = update("project", { id: project.id, name: newName.trim() }); + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + const handleDeleteClick = () => { + if (confirm(`Are you sure you want to delete project "${project.name}"?`)) { + /** + * In a classic centralized client-server app, we would fetch all todos + * for this project and delete them too. But that approach is wrong for + * distributed eventually consistent systems for two reasons: + * + * 1. Sync overhead scales with todo count (a project with 10k todos would + * generate 10k sync messages instead of just 1 for the project) + * 2. It wouldn't delete todos from other devices before they sync + * + * The correct approach for local-first systems: handle cascading logic in + * the UI layer. Queries filter out deleted projects, so their todos + * naturally become hidden. If a todo detail view is needed, it should + * check whether its parent project was deleted. + */ + update("project", { + id: project.id, + isDeleted: sqliteTrue, + }); + } + }; + + // Demonstrate JSON deserialization. Because FooJson is a branded type, + // we can safely deserialize without validation - TypeScript guarantees + // only validated JSON strings can have the FooJson brand. + const _foo = fooJsonToFoo(project.fooJson); + + return ( +
    +
    + +
    +

    {project.name}

    +
    +
    +
    + + +
    +
    + ); }; const AccountTab: FC = () => { - const evolu = useEvolu(); - const appOwner = use(evolu.appOwner); - - const [showMnemonic, setShowMnemonic] = useState(false); - - const handleRestoreAppOwnerClick = () => { - const mnemonic = window.prompt("Enter your mnemonic to restore your data:"); - if (mnemonic == null) return; - - const result = Mnemonic.from(mnemonic.trim()); - if (!result.ok) { - alert(formatTypeError(result.error)); - return; - } - - // void evolu.restoreAppOwner(result.value); - }; - - const handleResetAppOwnerClick = () => { - if (confirm("Are you sure? This will delete all your local data.")) { - // void evolu.resetAppOwner(); - } - }; - - const handleDownloadDatabaseClick = () => { - void evolu.exportDatabase().then((data) => { - using objectUrl = createObjectURL( - new Blob([data], { type: "application/x-sqlite3" }), - ); - - const link = document.createElement("a"); - link.href = objectUrl.url; - link.download = `${evolu.name}.sqlite3`; - link.click(); - }); - }; - - return ( -
    -

    - Todos are stored in local SQLite. When you sync across devices, your - data is end-to-end encrypted using your mnemonic. -

    - -
    -