···2526Read ours here: [Leaflet Lab Notes](https://lab.leaflet.pub/).
2728+### Local Development (Linux, WSL)
29+30+#### Prerequisites
31+32+- [NodeJS](https://nodejs.org/en) (version 20 or later)
33+- [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started)
34+- [Docker](https://docker.com) (required for local Supabase)
35+36+#### Installation
37+38+1. Clone the repository `git clone https://tangled.org/leaflet.pub/leaflet.git`
39+ 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`)
40+2. Install the dependencies: `npm install`
41+3. Install the Supabase CLI:
42+ - **macOS:** `brew install supabase/tap/supabase`
43+ - **Windows:** `scoop bucket add supabase https://github.com/supabase/scoop-bucket.git && scoop install supabase`
44+ - **Linux:** Use Homebrew or download packages from [releases page](https://github.com/supabase/cli/releases)
45+ - **Via npm:** The CLI is already included in package.json, use `npx supabase` for commands
46+47+#### Local Supabase Setup
48+49+1. Start the local Supabase stack: `npx supabase start`
50+ - First run takes longer while Docker images download
51+ - Once complete, you'll see connection details in the terminal output
52+ - Keep note of the `API URL`, `anon key`, `service_role key`, and `DB URL`
53+2. Copy the `.env` file example to `.env.local` and update with your local values from the previous step:
54+55+```env
56+# Supabase Configuration (from `supabase start` output)
57+NEXT_PUBLIC_SUPABASE_API_URL=http://localhost:54321
58+NEXT_PUBLIC_SUPABASE_ANON_KEY=your-local-anon-key-from-terminal
59+SUPABASE_SERVICE_ROLE_KEY=your-local-service-role-key-from-terminal
60+61+# Database (default local connection)
62+DB_URL=postgresql://postgres:postgres@localhost:54322/postgres
63+64+# Leaflet specific
65+LEAFLET_APP_PASSWORD=any-password-you-want
66+67+# Feed Service (for publication features, optional)
68+FEED_SERVICE_URL=http://localhost:3001
69+```
70+71+#### Database Migrations
72+73+1. Apply migrations to your local database:
74+ - First time setup: `npx supabase db reset` (resets database and applies all migrations)
75+ - Apply new migrations only: `npx supabase migration up` (applies unapplied migrations)
76+ - Note: You don't need to link to a remote project for local development
77+2. Access Supabase Studio at `http://localhost:54323` to view your local database
78+79+#### Running the App
80+81+1. `npm run dev` to start the development server
82+2. Visit `http://localhost:3000` in your browser
83+84+#### Stopping Local Supabase
85+86+- Run `npx supabase stop` to stop the local Supabase stack
87+- Add `--no-backup` flag to reset the database on next start
88+89+#### Feed service setup (optional)
90+91+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.
92+93+1. Clone the repo `git clone https://github.com/hyperlink-academy/leaflet-feeds.git`
94+2. Update your `.env.local` to include the FEED_SERVICE_URL (if not already set): `FEED_SERVICE_URL=http://localhost:3001`
95+3. Change to the directory and build the docker container `docker build -t leaflet-feeds .`
96+4. Run the docker container on port 3001 (to avoid conflicts with the main app): `docker run -p 3001:3000 leaflet-feeds`
97+98+#### Troubleshooting
99+100+- 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)
101+- 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).
102+103## Technical details
104105The stack:
+1-1
actions/publishToPublication.ts
···41import { Json } from "supabase/database.types";
42import { $Typed, UnicodeString } from "@atproto/api";
43import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
44-import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
45import { Lock } from "src/utils/lock";
46import type { PubLeafletPublication } from "lexicons/api";
47import {
···41import { Json } from "supabase/database.types";
42import { $Typed, UnicodeString } from "@atproto/api";
43import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
44+import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
45import { Lock } from "src/utils/lock";
46import type { PubLeafletPublication } from "lexicons/api";
47import {
···6import { drizzle } from "drizzle-orm/node-postgres";
7import { email_subscriptions_to_entity } from "drizzle/schema";
8import postgres from "postgres";
9-import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
10import type { Fact, PermissionToken } from "src/replicache";
11import type { Attribute } from "src/replicache/attributes";
12import { Database } from "supabase/database.types";
···6import { drizzle } from "drizzle-orm/node-postgres";
7import { email_subscriptions_to_entity } from "drizzle/schema";
8import postgres from "postgres";
9+import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
10import type { Fact, PermissionToken } from "src/replicache";
11import type { Attribute } from "src/replicache/attributes";
12import { Database } from "supabase/database.types";
···32import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
33import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
34import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
035import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
36import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
37import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
···79export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
80export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
81export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
082export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
83export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
84export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
···32import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
33import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
34import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
35+import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList'
36import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
37import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
38import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
···80export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
81export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
82export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
83+export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList'
84export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
85export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
86export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
···12import type * as PubLeafletBlocksText from './text'
13import type * as PubLeafletBlocksHeader from './header'
14import type * as PubLeafletBlocksImage from './image'
01516const is$typed = _is$typed,
17 validate = _validate
···39 | $Typed<PubLeafletBlocksHeader.Main>
40 | $Typed<PubLeafletBlocksImage.Main>
41 | { $type: string }
042 children?: ListItem[]
043}
4445const hashListItem = 'listItem'
···12import type * as PubLeafletBlocksText from './text'
13import type * as PubLeafletBlocksHeader from './header'
14import type * as PubLeafletBlocksImage from './image'
15+import type * as PubLeafletBlocksOrderedList from './orderedList'
1617const is$typed = _is$typed,
18 validate = _validate
···40 | $Typed<PubLeafletBlocksHeader.Main>
41 | $Typed<PubLeafletBlocksImage.Main>
42 | { $type: string }
43+ /** Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence. */
44 children?: ListItem[]
45+ orderedListChildren?: PubLeafletBlocksOrderedList.Main
46}
4748const hashListItem = 'listItem'
+2
lexicons/api/types/pub/leaflet/pages/canvas.ts
···15import type * as PubLeafletBlocksHeader from '../blocks/header'
16import type * as PubLeafletBlocksImage from '../blocks/image'
17import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList'
018import type * as PubLeafletBlocksWebsite from '../blocks/website'
19import type * as PubLeafletBlocksMath from '../blocks/math'
20import type * as PubLeafletBlocksCode from '../blocks/code'
···53 | $Typed<PubLeafletBlocksHeader.Main>
54 | $Typed<PubLeafletBlocksImage.Main>
55 | $Typed<PubLeafletBlocksUnorderedList.Main>
056 | $Typed<PubLeafletBlocksWebsite.Main>
57 | $Typed<PubLeafletBlocksMath.Main>
58 | $Typed<PubLeafletBlocksCode.Main>
···15import type * as PubLeafletBlocksHeader from '../blocks/header'
16import type * as PubLeafletBlocksImage from '../blocks/image'
17import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList'
18+import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList'
19import type * as PubLeafletBlocksWebsite from '../blocks/website'
20import type * as PubLeafletBlocksMath from '../blocks/math'
21import type * as PubLeafletBlocksCode from '../blocks/code'
···54 | $Typed<PubLeafletBlocksHeader.Main>
55 | $Typed<PubLeafletBlocksImage.Main>
56 | $Typed<PubLeafletBlocksUnorderedList.Main>
57+ | $Typed<PubLeafletBlocksOrderedList.Main>
58 | $Typed<PubLeafletBlocksWebsite.Main>
59 | $Typed<PubLeafletBlocksMath.Main>
60 | $Typed<PubLeafletBlocksCode.Main>
···15import type * as PubLeafletBlocksHeader from '../blocks/header'
16import type * as PubLeafletBlocksImage from '../blocks/image'
17import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList'
018import type * as PubLeafletBlocksWebsite from '../blocks/website'
19import type * as PubLeafletBlocksMath from '../blocks/math'
20import type * as PubLeafletBlocksCode from '../blocks/code'
···53 | $Typed<PubLeafletBlocksHeader.Main>
54 | $Typed<PubLeafletBlocksImage.Main>
55 | $Typed<PubLeafletBlocksUnorderedList.Main>
056 | $Typed<PubLeafletBlocksWebsite.Main>
57 | $Typed<PubLeafletBlocksMath.Main>
58 | $Typed<PubLeafletBlocksCode.Main>
···15import type * as PubLeafletBlocksHeader from '../blocks/header'
16import type * as PubLeafletBlocksImage from '../blocks/image'
17import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList'
18+import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList'
19import type * as PubLeafletBlocksWebsite from '../blocks/website'
20import type * as PubLeafletBlocksMath from '../blocks/math'
21import type * as PubLeafletBlocksCode from '../blocks/code'
···54 | $Typed<PubLeafletBlocksHeader.Main>
55 | $Typed<PubLeafletBlocksImage.Main>
56 | $Typed<PubLeafletBlocksUnorderedList.Main>
57+ | $Typed<PubLeafletBlocksOrderedList.Main>
58 | $Typed<PubLeafletBlocksWebsite.Main>
59 | $Typed<PubLeafletBlocksMath.Main>
60 | $Typed<PubLeafletBlocksCode.Main>
···212 newParent: string;
213 after: string;
214 block: string;
0215}> = async (args, ctx) => {
216 //we should be able to get normal siblings here as we care only about one level
217 let newSiblings = (
···225 (f) => f.data.value === args.block,
226 );
227 if (currentFactIndex === -1) return;
228- let currentSiblingsAfter = currentSiblings.slice(currentFactIndex + 1);
0000229 let currentChildren = (
230 await ctx.scanIndex.eav(args.block, "card/block")
231 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
···212 newParent: string;
213 after: string;
214 block: string;
215+ excludeFromSiblings?: string[];
216}> = async (args, ctx) => {
217 //we should be able to get normal siblings here as we care only about one level
218 let newSiblings = (
···226 (f) => f.data.value === args.block,
227 );
228 if (currentFactIndex === -1) return;
229+ // Filter out blocks that are being processed separately (e.g., in multi-select outdent)
230+ let excludeSet = new Set(args.excludeFromSiblings || []);
231+ let currentSiblingsAfter = currentSiblings
232+ .slice(currentFactIndex + 1)
233+ .filter((sib) => !excludeSet.has(sib.data.value));
234 let currentChildren = (
235 await ctx.scanIndex.eav(args.block, "card/block")
236 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
+5-4
src/utils/deleteBlock.ts
···2import { ReplicacheMutators } from "src/replicache";
3import { useUIState } from "src/useUIState";
4import { scanIndex } from "src/replicache/utils";
5-import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6import { focusBlock } from "src/utils/focusBlock";
7import { UndoManager } from "src/undoManager";
8···13) {
14 // get what pagess we need to close as a result of deleting this block
15 let pagesToClose = [] as string[];
016 for (let entity of entities) {
17 let [type] = await rep.query((tx) =>
18 scanIndex(tx).eav(entity, "block/type"),
···34 }
35 }
3637- // figure out what to focus
038 let focusedBlock = useUIState.getState().focusedEntity;
39 let parent =
40 focusedBlock?.entityType === "page"
···110111 // close the pages
112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
113- undoManager && undoManager.startGroup();
114115- // delete the blocks
116 await Promise.all(
117 entities.map((entity) =>
118 rep?.mutate.removeBlock({
···120 }),
121 ),
122 );
0123 undoManager && undoManager.endGroup();
124}
···2import { ReplicacheMutators } from "src/replicache";
3import { useUIState } from "src/useUIState";
4import { scanIndex } from "src/replicache/utils";
5+import { getBlocksWithType } from "src/replicache/getBlocks";
6import { focusBlock } from "src/utils/focusBlock";
7import { UndoManager } from "src/undoManager";
8···13) {
14 // get what pagess we need to close as a result of deleting this block
15 let pagesToClose = [] as string[];
16+17 for (let entity of entities) {
18 let [type] = await rep.query((tx) =>
19 scanIndex(tx).eav(entity, "block/type"),
···35 }
36 }
3738+ // the next and previous blocks in the block list
39+ // if the focused thing is a page and not a block, return
40 let focusedBlock = useUIState.getState().focusedEntity;
41 let parent =
42 focusedBlock?.entityType === "page"
···112113 // close the pages
114 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
01150116 await Promise.all(
117 entities.map((entity) =>
118 rep?.mutate.removeBlock({
···120 }),
121 ),
122 );
123+124 undoManager && undoManager.endGroup();
125}