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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,81 @@ Use Publications on Leaflet for blogs, newsletters, project logs — anything yo

Read ours here: [Leaflet Lab Notes](https://lab.leaflet.pub/).

### Local Development (Linux, WSL)

#### Prerequisites

- [NodeJS](https://nodejs.org/en) (version 20 or later)
- [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started)
- [Docker](https://docker.com) (required for local Supabase)

#### Installation

1. Clone the repository `git clone https://tangled.org/leaflet.pub/leaflet.git`
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`)
2. Install the dependencies: `npm install`
3. Install the Supabase CLI:
- **macOS:** `brew install supabase/tap/supabase`
- **Windows:** `scoop bucket add supabase https://github.com/supabase/scoop-bucket.git && scoop install supabase`
- **Linux:** Use Homebrew or download packages from [releases page](https://github.com/supabase/cli/releases)
- **Via npm:** The CLI is already included in package.json, use `npx supabase` for commands

#### Local Supabase Setup

1. Start the local Supabase stack: `npx supabase start`
- First run takes longer while Docker images download
- Once complete, you'll see connection details in the terminal output
- Keep note of the `API URL`, `anon key`, `service_role key`, and `DB URL`
2. Copy the `.env` file example to `.env.local` and update with your local values from the previous step:

```env
# Supabase Configuration (from `supabase start` output)
NEXT_PUBLIC_SUPABASE_API_URL=http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-local-anon-key-from-terminal
SUPABASE_SERVICE_ROLE_KEY=your-local-service-role-key-from-terminal

# Database (default local connection)
DB_URL=postgresql://postgres:postgres@localhost:54322/postgres

# Leaflet specific
LEAFLET_APP_PASSWORD=any-password-you-want

# Feed Service (for publication features, optional)
FEED_SERVICE_URL=http://localhost:3001
```

#### Database Migrations

1. Apply migrations to your local database:
- First time setup: `npx supabase db reset` (resets database and applies all migrations)
- Apply new migrations only: `npx supabase migration up` (applies unapplied migrations)
- Note: You don't need to link to a remote project for local development
2. Access Supabase Studio at `http://localhost:54323` to view your local database

#### Running the App

1. `npm run dev` to start the development server
2. Visit `http://localhost:3000` in your browser

#### Stopping Local Supabase

- Run `npx supabase stop` to stop the local Supabase stack
- Add `--no-backup` flag to reset the database on next start

#### Feed service setup (optional)

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.

1. Clone the repo `git clone https://github.com/hyperlink-academy/leaflet-feeds.git`
2. Update your `.env.local` to include the FEED_SERVICE_URL (if not already set): `FEED_SERVICE_URL=http://localhost:3001`
3. Change to the directory and build the docker container `docker build -t leaflet-feeds .`
4. Run the docker container on port 3001 (to avoid conflicts with the main app): `docker run -p 3001:3000 leaflet-feeds`

#### Troubleshooting

- 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)
- 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).

## Technical details

The stack:
Expand Down
2 changes: 1 addition & 1 deletion actions/publishToPublication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { AtUri } from "@atproto/syntax";
import { Json } from "supabase/database.types";
import { $Typed, UnicodeString } from "@atproto/api";
import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
import { Lock } from "src/utils/lock";
import type { PubLeafletPublication } from "lexicons/api";
import {
Expand Down
2 changes: 1 addition & 1 deletion actions/subscriptions/subscribeToMailboxWithEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import { email_subscriptions_to_entity } from "drizzle/schema";
import postgres from "postgres";
import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
import type { Fact, PermissionToken } from "src/replicache";
import type { Attribute } from "src/replicache/attributes";
import { Database } from "supabase/database.types";
Expand Down
73 changes: 73 additions & 0 deletions app/lish/[did]/[publication]/[rkey]/PostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PubLeafletBlocksImage,
PubLeafletBlocksText,
PubLeafletBlocksUnorderedList,
PubLeafletBlocksOrderedList,
PubLeafletBlocksWebsite,
PubLeafletDocument,
PubLeafletPagesLinearDocument,
Expand Down Expand Up @@ -238,6 +239,26 @@ export let Block = ({
</ul>
);
}
case PubLeafletBlocksOrderedList.isMain(b.block): {
return (
<ol className="-ml-px sm:ml-[9px] pb-2" start={b.block.startIndex || 1}>
{b.block.children.map((child, i) => (
<OrderedListItem
pollData={pollData}
pages={pages}
bskyPostData={bskyPostData}
index={[...index, i]}
item={child}
did={did}
key={i}
className={className}
pageId={pageId}
startIndex={b.block.startIndex || 1}
/>
))}
</ol>
);
}
case PubLeafletBlocksMath.isMain(b.block): {
return <StaticMathBlock block={b.block} />;
}
Expand Down Expand Up @@ -459,3 +480,55 @@ function ListItem(props: {
</li>
);
}

function OrderedListItem(props: {
index: number[];
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
item: PubLeafletBlocksOrderedList.ListItem;
did: string;
className?: string;
bskyPostData: AppBskyFeedDefs.PostView[];
pollData: PollData[];
pageId?: string;
startIndex?: number;
}) {
const calculatedIndex = (props.startIndex || 1) + props.index[props.index.length - 1];
let children = props.item.children?.length ? (
<ol className="-ml-[7px] sm:ml-[7px]">
{props.item.children.map((child, index) => (
<OrderedListItem
pages={props.pages}
pollData={props.pollData}
bskyPostData={props.bskyPostData}
index={[...props.index, index]}
item={child}
did={props.did}
key={index}
className={props.className}
pageId={props.pageId}
startIndex={props.startIndex}
/>
))}
</ol>
) : null;
return (
<li className={`pb-0! flex flex-row gap-2`}>
<div className="listMarker shrink-0 mx-2 z-1 mt-[14px]">
{calculatedIndex}.
</div>
<div className="flex flex-col w-full">
<Block
pollData={props.pollData}
pages={props.pages}
bskyPostData={props.bskyPostData}
block={{ block: props.item.content }}
did={props.did}
isList
index={props.index}
pageId={props.pageId}
/>
{children}{" "}
</div>
</li>
);
}
98 changes: 87 additions & 11 deletions components/Blocks/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,11 @@ import { PollBlock } from "./PollBlock";
import { BlueskyPostBlock } from "./BlueskyPostBlock";
import { CheckboxChecked } from "components/Icons/CheckboxChecked";
import { CheckboxEmpty } from "components/Icons/CheckboxEmpty";
import { LockTiny } from "components/Icons/LockTiny";
import { MathBlock } from "./MathBlock";
import { CodeBlock } from "./CodeBlock";
import { HorizontalRule } from "./HorizontalRule";
import { deepEquals } from "src/utils/deepEquals";
import { isTextBlock } from "src/utils/isTextBlock";
import { focusPage } from "src/utils/focusPage";
import { DeleteTiny } from "components/Icons/DeleteTiny";
import { ArrowDownTiny } from "components/Icons/ArrowDownTiny";
import { Separator } from "components/Layout";
Expand All @@ -47,6 +45,9 @@ export type Block = {
type: Fact<"block/type">["data"]["value"];
listData?: {
checklist?: boolean;
listStyle?: "ordered" | "unordered";
listStart?: number;
displayNumber?: number;
path: { depth: number; entity: string }[];
parent: string;
depth: number;
Expand Down Expand Up @@ -192,7 +193,9 @@ function deepEqualsBlockProps(
if (
prevProps.listData.checklist !== nextProps.listData.checklist ||
prevProps.listData.parent !== nextProps.listData.parent ||
prevProps.listData.depth !== nextProps.listData.depth
prevProps.listData.depth !== nextProps.listData.depth ||
prevProps.listData.displayNumber !== nextProps.listData.displayNumber ||
prevProps.listData.listStyle !== nextProps.listData.listStyle
) {
return false;
}
Expand Down Expand Up @@ -495,6 +498,7 @@ export const ListMarker = (
) => {
let isMobile = useIsMobile();
let checklist = useEntity(props.value, "block/check-list");
let listStyle = useEntity(props.value, "block/list-style");
let headingLevel = useEntity(props.value, "block/heading-level")?.data.value;
let children = useEntity(props.value, "card/block");
let folded =
Expand All @@ -504,6 +508,43 @@ export const ListMarker = (
let depth = props.listData?.depth;
let { permissions } = useEntitySetContext();
let { rep } = useReplicache();

let [editingNumber, setEditingNumber] = useState(false);
let [numberInputValue, setNumberInputValue] = useState("");

useEffect(() => {
if (!editingNumber) {
setNumberInputValue("");
}
}, [editingNumber]);

const handleNumberSave = async () => {
if (!rep || !props.listData) return;

const newNumber = parseInt(numberInputValue, 10);
if (isNaN(newNumber) || newNumber < 1) {
setEditingNumber(false);
return;
}

const currentDisplay = props.listData.displayNumber || 1;

if (newNumber === currentDisplay) {
// Remove override if it matches the computed number
await rep.mutate.retractAttribute({
entity: props.value,
attribute: "block/list-number",
});
} else {
await rep.mutate.assertFact({
entity: props.value,
attribute: "block/list-number",
data: { type: "number", value: newNumber },
});
}

setEditingNumber(false);
};
return (
<div
className={`shrink-0 flex justify-end items-center h-3 z-1
Expand Down Expand Up @@ -531,14 +572,49 @@ export const ListMarker = (
}}
className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`}
>
<div
className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-offset-1
${
folded
? "outline-secondary"
: ` ${children.length > 0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}`
}`}
/>
{listStyle?.data.value === "ordered" ? (
editingNumber ? (
<input
type="text"
value={numberInputValue}
onChange={(e) => setNumberInputValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={handleNumberSave}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleNumberSave();
} else if (e.key === "Escape") {
setEditingNumber(false);
}
}}
autoFocus
className="text-secondary font-normal text-right min-w-[2rem] w-[2rem] border border-border rounded-md px-1 py-0.5 focus:border-tertiary focus:outline-solid focus:outline-tertiary focus:outline-2 focus:outline-offset-1"
/>
) : (
<div
className="text-secondary font-normal text-right w-[2rem] cursor-pointer hover:text-primary"
onClick={(e) => {
e.stopPropagation();
if (permissions.write && listStyle?.data.value === "ordered") {
setNumberInputValue(String(props.listData?.displayNumber || 1));
setEditingNumber(true);
}
}}
>
{props.listData?.displayNumber || 1}.
</div>
)
) : (
<div
className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-offset-1
${
folded
? "outline-secondary"
: ` ${children.length > 0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}`
}`}
/>
)}
</button>
{checklist && (
<button
Expand Down
25 changes: 23 additions & 2 deletions components/Blocks/BlockCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
} from "components/Icons/BlockTextSmall";
import { LinkSmall } from "components/Icons/LinkSmall";
import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall";
import { ListUnorderedSmall } from "components/Toolbar/ListToolbar";
import { ListUnorderedSmall, ListOrderedSmall } from "components/Toolbar/ListToolbar";
import { BlockMathSmall } from "components/Icons/BlockMathSmall";
import { BlockCodeSmall } from "components/Icons/BlockCodeSmall";
import { QuoteSmall } from "components/Icons/QuoteSmall";
Expand Down Expand Up @@ -151,7 +151,7 @@ export const blockCommands: Command[] = [
},
},
{
name: "List",
name: "Unordered List",
icon: <ListUnorderedSmall />,
type: "text",
onSelect: async (rep, props, um) => {
Expand All @@ -164,6 +164,27 @@ export const blockCommands: Command[] = [
clearCommandSearchText(entity);
},
},
{
name: "Ordered List",
icon: <ListOrderedSmall />,
type: "text",
onSelect: async (rep, props, um) => {
let entity = await createBlockWithType(rep, props, "text");
await rep?.mutate.assertFact([
{
entity,
attribute: "block/is-list",
data: { value: true, type: "boolean" },
},
{
entity,
attribute: "block/list-style",
data: { value: "ordered", type: "list-style-union" },
},
]);
clearCommandSearchText(entity);
},
},
{
name: "Block Quote",
icon: <QuoteSmall />,
Expand Down
2 changes: 1 addition & 1 deletion components/Blocks/MailboxBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubs
import { focusPage } from "src/utils/focusPage";
import { v7 } from "uuid";
import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers";
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
import { getBlocksWithType } from "src/replicache/getBlocks";
import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML";
import { htmlToMarkdown } from "src/htmlMarkdownParsers";
import {
Expand Down
Loading