···25252626Read ours here: [Leaflet Lab Notes](https://lab.leaflet.pub/).
27272828+### Local Development (Linux, WSL)
2929+3030+#### Prerequisites
3131+3232+- [NodeJS](https://nodejs.org/en) (version 20 or later)
3333+- [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started)
3434+- [Docker](https://docker.com) (required for local Supabase)
3535+3636+#### Installation
3737+3838+1. Clone the repository `git clone https://tangled.org/leaflet.pub/leaflet.git`
3939+ 1. If using WSL, it's recommended to install in the native file structure vs in a mounted Windows file structure (i.e, prefer installing at `~/code/leaflet` vs `/mnt/c/code/leaflet`)
4040+2. Install the dependencies: `npm install`
4141+3. Install the Supabase CLI:
4242+ - **macOS:** `brew install supabase/tap/supabase`
4343+ - **Windows:** `scoop bucket add supabase https://github.com/supabase/scoop-bucket.git && scoop install supabase`
4444+ - **Linux:** Use Homebrew or download packages from [releases page](https://github.com/supabase/cli/releases)
4545+ - **Via npm:** The CLI is already included in package.json, use `npx supabase` for commands
4646+4747+#### Local Supabase Setup
4848+4949+1. Start the local Supabase stack: `npx supabase start`
5050+ - First run takes longer while Docker images download
5151+ - Once complete, you'll see connection details in the terminal output
5252+ - Keep note of the `API URL`, `anon key`, `service_role key`, and `DB URL`
5353+2. Copy the `.env` file example to `.env.local` and update with your local values from the previous step:
5454+5555+```env
5656+# Supabase Configuration (from `supabase start` output)
5757+NEXT_PUBLIC_SUPABASE_API_URL=http://localhost:54321
5858+NEXT_PUBLIC_SUPABASE_ANON_KEY=your-local-anon-key-from-terminal
5959+SUPABASE_SERVICE_ROLE_KEY=your-local-service-role-key-from-terminal
6060+6161+# Database (default local connection)
6262+DB_URL=postgresql://postgres:postgres@localhost:54322/postgres
6363+6464+# Leaflet specific
6565+LEAFLET_APP_PASSWORD=any-password-you-want
6666+6767+# Feed Service (for publication features, optional)
6868+FEED_SERVICE_URL=http://localhost:3001
6969+```
7070+7171+#### Database Migrations
7272+7373+1. Apply migrations to your local database:
7474+ - First time setup: `npx supabase db reset` (resets database and applies all migrations)
7575+ - Apply new migrations only: `npx supabase migration up` (applies unapplied migrations)
7676+ - Note: You don't need to link to a remote project for local development
7777+2. Access Supabase Studio at `http://localhost:54323` to view your local database
7878+7979+#### Running the App
8080+8181+1. `npm run dev` to start the development server
8282+2. Visit `http://localhost:3000` in your browser
8383+8484+#### Stopping Local Supabase
8585+8686+- Run `npx supabase stop` to stop the local Supabase stack
8787+- Add `--no-backup` flag to reset the database on next start
8888+8989+#### Feed service setup (optional)
9090+9191+Setup instructions to run a local feed service from a docker container. This step isn't necessary if you're not working on publication or BlueSky integration features.
9292+9393+1. Clone the repo `git clone https://github.com/hyperlink-academy/leaflet-feeds.git`
9494+2. Update your `.env.local` to include the FEED_SERVICE_URL (if not already set): `FEED_SERVICE_URL=http://localhost:3001`
9595+3. Change to the directory and build the docker container `docker build -t leaflet-feeds .`
9696+4. Run the docker container on port 3001 (to avoid conflicts with the main app): `docker run -p 3001:3000 leaflet-feeds`
9797+9898+#### Troubleshooting
9999+100100+- Persisting articles on a fresh install over a fresh DB are usually due to stale Replicache entrys. To clear, open your browser DevTools and delete Replicache entries (usually under IndexedDB Storage)
101101+- Supabase settings will get cached in `.next`; if you change where you're pointing your supabase connections to you may need to delete the `.next` folder (it will rebuild next time you start the app).
102102+28103## Technical details
2910430105The stack:
+1-1
actions/publishToPublication.ts
···4141import { Json } from "supabase/database.types";
4242import { $Typed, UnicodeString } from "@atproto/api";
4343import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
4444-import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
4444+import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
4545import { Lock } from "src/utils/lock";
4646import type { PubLeafletPublication } from "lexicons/api";
4747import {
···66import { drizzle } from "drizzle-orm/node-postgres";
77import { email_subscriptions_to_entity } from "drizzle/schema";
88import postgres from "postgres";
99-import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
99+import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
1010import type { Fact, PermissionToken } from "src/replicache";
1111import type { Attribute } from "src/replicache/attributes";
1212import { Database } from "supabase/database.types";
···3232import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
3333import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
3434import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
3535+import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList'
3536import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
3637import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
3738import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
···7980export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
8081export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
8182export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
8383+export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList'
8284export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
8385export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
8486export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
···1212import type * as PubLeafletBlocksText from './text'
1313import type * as PubLeafletBlocksHeader from './header'
1414import type * as PubLeafletBlocksImage from './image'
1515+import type * as PubLeafletBlocksOrderedList from './orderedList'
15161617const is$typed = _is$typed,
1718 validate = _validate
···3940 | $Typed<PubLeafletBlocksHeader.Main>
4041 | $Typed<PubLeafletBlocksImage.Main>
4142 | { $type: string }
4343+ /** Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence. */
4244 children?: ListItem[]
4545+ orderedListChildren?: PubLeafletBlocksOrderedList.Main
4346}
44474548const hashListItem = 'listItem'
+2
lexicons/api/types/pub/leaflet/pages/canvas.ts
···1515import type * as PubLeafletBlocksHeader from '../blocks/header'
1616import type * as PubLeafletBlocksImage from '../blocks/image'
1717import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList'
1818+import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList'
1819import type * as PubLeafletBlocksWebsite from '../blocks/website'
1920import type * as PubLeafletBlocksMath from '../blocks/math'
2021import type * as PubLeafletBlocksCode from '../blocks/code'
···5354 | $Typed<PubLeafletBlocksHeader.Main>
5455 | $Typed<PubLeafletBlocksImage.Main>
5556 | $Typed<PubLeafletBlocksUnorderedList.Main>
5757+ | $Typed<PubLeafletBlocksOrderedList.Main>
5658 | $Typed<PubLeafletBlocksWebsite.Main>
5759 | $Typed<PubLeafletBlocksMath.Main>
5860 | $Typed<PubLeafletBlocksCode.Main>
···1515import type * as PubLeafletBlocksHeader from '../blocks/header'
1616import type * as PubLeafletBlocksImage from '../blocks/image'
1717import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList'
1818+import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList'
1819import type * as PubLeafletBlocksWebsite from '../blocks/website'
1920import type * as PubLeafletBlocksMath from '../blocks/math'
2021import type * as PubLeafletBlocksCode from '../blocks/code'
···5354 | $Typed<PubLeafletBlocksHeader.Main>
5455 | $Typed<PubLeafletBlocksImage.Main>
5556 | $Typed<PubLeafletBlocksUnorderedList.Main>
5757+ | $Typed<PubLeafletBlocksOrderedList.Main>
5658 | $Typed<PubLeafletBlocksWebsite.Main>
5759 | $Typed<PubLeafletBlocksMath.Main>
5860 | $Typed<PubLeafletBlocksCode.Main>
+54
lexicons/pub/leaflet/blocks/orderedList.json
···11+{
22+ "lexicon": 1,
33+ "id": "pub.leaflet.blocks.orderedList",
44+ "defs": {
55+ "main": {
66+ "type": "object",
77+ "required": [
88+ "children"
99+ ],
1010+ "properties": {
1111+ "startIndex": {
1212+ "type": "integer",
1313+ "description": "The starting number for this ordered list. Defaults to 1 if not specified."
1414+ },
1515+ "children": {
1616+ "type": "array",
1717+ "items": {
1818+ "type": "ref",
1919+ "ref": "#listItem"
2020+ }
2121+ }
2222+ }
2323+ },
2424+ "listItem": {
2525+ "type": "object",
2626+ "required": [
2727+ "content"
2828+ ],
2929+ "properties": {
3030+ "content": {
3131+ "type": "union",
3232+ "refs": [
3333+ "pub.leaflet.blocks.text",
3434+ "pub.leaflet.blocks.header",
3535+ "pub.leaflet.blocks.image"
3636+ ]
3737+ },
3838+ "children": {
3939+ "type": "array",
4040+ "description": "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.",
4141+ "items": {
4242+ "type": "ref",
4343+ "ref": "#listItem"
4444+ }
4545+ },
4646+ "unorderedListChildren": {
4747+ "type": "ref",
4848+ "description": "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.",
4949+ "ref": "pub.leaflet.blocks.unorderedList"
5050+ }
5151+ }
5252+ }
5353+ }
5454+}
+6
lexicons/pub/leaflet/blocks/unorderedList.json
···3333 },
3434 "children": {
3535 "type": "array",
3636+ "description": "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.",
3637 "items": {
3738 "type": "ref",
3839 "ref": "#listItem"
3940 }
4141+ },
4242+ "orderedListChildren": {
4343+ "type": "ref",
4444+ "description": "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.",
4545+ "ref": "pub.leaflet.blocks.orderedList"
4046 }
4147 }
4248 }
···212212 newParent: string;
213213 after: string;
214214 block: string;
215215+ excludeFromSiblings?: string[];
215216}> = async (args, ctx) => {
216217 //we should be able to get normal siblings here as we care only about one level
217218 let newSiblings = (
···225226 (f) => f.data.value === args.block,
226227 );
227228 if (currentFactIndex === -1) return;
228228- let currentSiblingsAfter = currentSiblings.slice(currentFactIndex + 1);
229229+ // Filter out blocks that are being processed separately (e.g., in multi-select outdent)
230230+ let excludeSet = new Set(args.excludeFromSiblings || []);
231231+ let currentSiblingsAfter = currentSiblings
232232+ .slice(currentFactIndex + 1)
233233+ .filter((sib) => !excludeSet.has(sib.data.value));
229234 let currentChildren = (
230235 await ctx.scanIndex.eav(args.block, "card/block")
231236 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
+5-4
src/utils/deleteBlock.ts
···22import { ReplicacheMutators } from "src/replicache";
33import { useUIState } from "src/useUIState";
44import { scanIndex } from "src/replicache/utils";
55-import { getBlocksWithType } from "src/hooks/queries/useBlocks";
55+import { getBlocksWithType } from "src/replicache/getBlocks";
66import { focusBlock } from "src/utils/focusBlock";
77import { UndoManager } from "src/undoManager";
88···1313) {
1414 // get what pagess we need to close as a result of deleting this block
1515 let pagesToClose = [] as string[];
1616+1617 for (let entity of entities) {
1718 let [type] = await rep.query((tx) =>
1819 scanIndex(tx).eav(entity, "block/type"),
···3435 }
3536 }
36373737- // figure out what to focus
3838+ // the next and previous blocks in the block list
3939+ // if the focused thing is a page and not a block, return
3840 let focusedBlock = useUIState.getState().focusedEntity;
3941 let parent =
4042 focusedBlock?.entityType === "page"
···110112111113 // close the pages
112114 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
113113- undoManager && undoManager.startGroup();
114115115115- // delete the blocks
116116 await Promise.all(
117117 entities.map((entity) =>
118118 rep?.mutate.removeBlock({
···120120 }),
121121 ),
122122 );
123123+123124 undoManager && undoManager.endGroup();
124125}
+13-4
src/utils/getBlocksAsHTML.tsx
···1616 let parsed = parseBlocksToList(selectedBlocks);
1717 for (let pb of parsed) {
1818 if (pb.type === "block") result.push(await renderBlock(pb.block, tx));
1919- else
1919+ else {
2020+ // Check if the first child is an ordered list
2121+ let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered";
2222+ let tag = isOrdered ? "ol" : "ul";
2023 result.push(
2121- `<ul>${(
2424+ `<${tag}>${(
2225 await Promise.all(
2326 pb.children.map(async (c) => await renderList(c, tx)),
2427 )
2528 ).join("\n")}
2626- </ul>`,
2929+ </${tag}>`,
2730 );
3131+ }
2832 }
2933 return result;
3034 });
···3640 await Promise.all(l.children.map(async (c) => await renderList(c, tx)))
3741 ).join("\n");
3842 let [checked] = await scanIndex(tx).eav(l.block.value, "block/check-list");
4343+4444+ // Check if nested children are ordered or unordered
4545+ let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered";
4646+ let tag = isOrdered ? "ol" : "ul";
4747+3948 return `<li ${checked ? `data-checked=${checked.data.value}` : ""}>${await renderBlock(l.block, tx)} ${
4049 l.children.length > 0
4150 ? `
4242- <ul>${children}</ul>
5151+ <${tag}>${children}</${tag}>
4352 `
4453 : ""
4554 }</li>`;
+110-27
src/utils/list-operations.ts
···11import { Block } from "components/Blocks/Block";
22import { Replicache } from "replicache";
33import type { ReplicacheMutators } from "src/replicache";
44-import { useUIState } from "src/useUIState";
54import { v7 } from "uuid";
6577-export function indent(
66+export function orderListItems(
77+ block: Block,
88+ rep?: Replicache<ReplicacheMutators> | null,
99+) {
1010+ if (!block.listData) return;
1111+ rep?.mutate.assertFact({
1212+ entity: block.value,
1313+ attribute: "block/list-style",
1414+ data: { type: "list-style-union", value: "ordered" },
1515+ });
1616+}
1717+1818+export function unorderListItems(
1919+ block: Block,
2020+ rep?: Replicache<ReplicacheMutators> | null,
2121+) {
2222+ if (!block.listData) return;
2323+ // Remove list-style attribute to convert back to unordered
2424+ rep?.mutate.retractAttribute({
2525+ entity: block.value,
2626+ attribute: "block/list-style",
2727+ });
2828+}
2929+3030+export async function indent(
831 block: Block,
932 previousBlock?: Block,
1033 rep?: Replicache<ReplicacheMutators> | null,
1111-) {
1212- if (!block.listData) return false;
1313- if (!previousBlock?.listData) return false;
3434+ foldState?: {
3535+ foldedBlocks: string[];
3636+ toggleFold: (entityID: string) => void;
3737+ },
3838+): Promise<{ success: boolean }> {
3939+ if (!block.listData) return { success: false };
4040+4141+ // All lists use parent/child structure - move to new parent
4242+ if (!previousBlock?.listData) return { success: false };
1443 let depth = block.listData.depth;
1544 let newParent = previousBlock.listData.path.find((f) => f.depth === depth);
1616- if (!newParent) return false;
1717- if (useUIState.getState().foldedBlocks.includes(newParent.entity))
1818- useUIState.getState().toggleFold(newParent.entity);
4545+ if (!newParent) return { success: false };
4646+ if (foldState && foldState.foldedBlocks.includes(newParent.entity))
4747+ foldState.toggleFold(newParent.entity);
1948 rep?.mutate.retractFact({ factID: block.factID });
2049 rep?.mutate.addLastBlock({
2150 parent: newParent.entity,
2251 factID: v7(),
2352 entity: block.value,
2453 });
2525- return true;
5454+5555+ return { success: true };
2656}
27572858export function outdentFull(
···3868 data: { type: "boolean", value: false },
3969 });
40704141- // find the next block that is a level 1 list item or not a list item.
4242- // If there are none or this block is a level 1 list item, we don't need to move anything
4343-4471 let after = block.listData?.path.find((f) => f.depth === 1)?.entity;
45724646- // move this block to be after that block
4773 after &&
4874 after !== block.value &&
4975 rep?.mutate.moveBlock({
···6187 });
6288}
63896464-export function outdent(
9090+export async function outdent(
6591 block: Block,
6666- previousBlock: Block | null,
9292+ previousBlock?: Block | null,
6793 rep?: Replicache<ReplicacheMutators> | null,
6868-) {
6969- if (!block.listData) return false;
9494+ foldState?: {
9595+ foldedBlocks: string[];
9696+ toggleFold: (entityID: string) => void;
9797+ },
9898+ excludeFromSiblings?: string[],
9999+): Promise<{ success: boolean }> {
100100+ if (!block.listData) return { success: false };
70101 let listData = block.listData;
102102+103103+ // All lists use parent/child structure - move blocks between parents
71104 if (listData.depth === 1) {
7272- rep?.mutate.assertFact({
105105+ await rep?.mutate.assertFact({
73106 entity: block.value,
74107 attribute: "block/is-list",
75108 data: { type: "boolean", value: false },
76109 });
7777- rep?.mutate.moveChildren({
110110+ await rep?.mutate.moveChildren({
78111 oldParent: block.value,
79112 newParent: block.parent,
80113 after: block.value,
81114 });
115115+ return { success: true };
82116 } else {
8383- if (!previousBlock || !previousBlock.listData) return false;
8484- let after = previousBlock.listData.path.find(
117117+ // Use block's own path for ancestry lookups - it always has correct info
118118+ // even in multiselect scenarios where previousBlock may be stale
119119+ let after = listData.path.find(
85120 (f) => f.depth === listData.depth - 1,
86121 )?.entity;
8787- if (!after) return false;
122122+ if (!after) return { success: false };
88123 let parent: string | undefined = undefined;
89124 if (listData.depth === 2) {
90125 parent = block.parent;
91126 } else {
9292- parent = previousBlock.listData.path.find(
127127+ parent = listData.path.find(
93128 (f) => f.depth === listData.depth - 2,
94129 )?.entity;
95130 }
9696- if (!parent) return false;
9797- if (useUIState.getState().foldedBlocks.includes(parent))
9898- useUIState.getState().toggleFold(parent);
9999- rep?.mutate.outdentBlock({
131131+ if (!parent) return { success: false };
132132+ if (foldState && foldState.foldedBlocks.includes(parent))
133133+ foldState.toggleFold(parent);
134134+ await rep?.mutate.outdentBlock({
100135 block: block.value,
101136 newParent: parent,
102137 oldParent: listData.parent,
103138 after,
139139+ excludeFromSiblings,
104140 });
141141+142142+ return { success: true };
143143+ }
144144+}
145145+146146+export async function multiSelectOutdent(
147147+ sortedSelection: Block[],
148148+ siblings: Block[],
149149+ rep: Replicache<ReplicacheMutators>,
150150+ foldState: { foldedBlocks: string[]; toggleFold: (entityID: string) => void },
151151+): Promise<void> {
152152+ let pageParent = siblings[0]?.parent;
153153+ if (!pageParent) return;
154154+155155+ let selectedSet = new Set(sortedSelection.map((b) => b.value));
156156+ let selectedEntities = sortedSelection.map((b) => b.value);
157157+158158+ // Check if all selected list items are at depth 1 → convert to text
159159+ let allAtDepth1 = sortedSelection.every(
160160+ (b) => !b.listData || b.listData.depth === 1,
161161+ );
162162+163163+ if (allAtDepth1) {
164164+ // Convert depth-1 items to plain text (outdent handles this)
165165+ for (let i = siblings.length - 1; i >= 0; i--) {
166166+ let block = siblings[i];
167167+ if (!selectedSet.has(block.value)) continue;
168168+ if (!block.listData) continue;
169169+ await outdent(block, null, rep, foldState, selectedEntities);
170170+ }
171171+ } else {
172172+ // Normal outdent: iterate backward through siblings
173173+ for (let i = siblings.length - 1; i >= 0; i--) {
174174+ let block = siblings[i];
175175+ if (!selectedSet.has(block.value)) continue;
176176+ if (!block.listData) continue;
177177+ if (block.listData.depth === 1) continue;
178178+179179+ // Skip if parent is selected AND parent's depth > 1
180180+ let parentEntity = block.listData.parent;
181181+ if (selectedSet.has(parentEntity)) {
182182+ let parentBlock = siblings.find((s) => s.value === parentEntity);
183183+ if (parentBlock?.listData && parentBlock.listData.depth > 1) continue;
184184+ }
185185+186186+ await outdent(block, null, rep, foldState, selectedEntities);
187187+ }
105188 }
106189}