a tool for shared writing and social publishing

Merge main into feature/analytics

Resolve merge conflicts:
- AccountSettings.tsx: keep expanded settings menu with Pro subscription
- inngest/route.tsx: include both stripe functions and sync_document_metadata
- ProfileHeader.tsx: trivial whitespace conflict
- Install missing @yornaath/batshit dependency from main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+4364 -2889
-298
.claude/skills/lexicons.md
··· 1 - # Lexicon System 2 - 3 - ## Overview 4 - 5 - Lexicons define the schema for AT Protocol records. This project has two namespaces: 6 - - **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) 7 - - **`site.standard.*`** - Standard site lexicons for interoperability 8 - 9 - The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`. 10 - 11 - ## Key Files 12 - 13 - - **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons 14 - - **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) 15 - - **`lexicons/build.ts`** - Builds TypeScript sources to JSON 16 - - **`lexicons/api/`** - Generated TypeScript types and client 17 - - **`package.json`** - Contains `lexgen` script 18 - 19 - ## Running Lexicon Generation 20 - 21 - ```bash 22 - npm run lexgen 23 - ``` 24 - 25 - This runs: 26 - 1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript 27 - 2. `lex gen-api` - Generates TypeScript types from all JSON lexicons 28 - 3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions 29 - 30 - ## Adding a New pub.leaflet Lexicon 31 - 32 - ### 1. Create the Source Definition 33 - 34 - Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): 35 - 36 - ```typescript 37 - import { LexiconDoc } from "@atproto/lexicon"; 38 - 39 - export const PubLeafletMyLexicon: LexiconDoc = { 40 - lexicon: 1, 41 - id: "pub.leaflet.myLexicon", 42 - defs: { 43 - main: { 44 - type: "record", // or "object" for non-record types 45 - key: "tid", 46 - record: { 47 - type: "object", 48 - required: ["field1"], 49 - properties: { 50 - field1: { type: "string", maxLength: 1000 }, 51 - field2: { type: "integer", minimum: 0 }, 52 - optionalRef: { type: "ref", ref: "other.lexicon#def" }, 53 - }, 54 - }, 55 - }, 56 - // Additional defs for sub-objects 57 - subType: { 58 - type: "object", 59 - properties: { 60 - nested: { type: "string" }, 61 - }, 62 - }, 63 - }, 64 - }; 65 - ``` 66 - 67 - ### 2. Add to Build 68 - 69 - Update `lexicons/build.ts`: 70 - 71 - ```typescript 72 - import { PubLeafletMyLexicon } from "./src/myLexicon"; 73 - 74 - const lexicons = [ 75 - // ... existing lexicons 76 - PubLeafletMyLexicon, 77 - ]; 78 - ``` 79 - 80 - ### 3. Update lexgen Command (if needed) 81 - 82 - If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: 83 - 84 - ```json 85 - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." 86 - ``` 87 - 88 - Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. 89 - 90 - ### 4. Add to authFullPermissions (for record types) 91 - 92 - If your lexicon is a record type that users should be able to create/update/delete, add it to the `authFullPermissions` permission set in `lexicons/src/authFullPermissions.ts`: 93 - 94 - ```typescript 95 - import { PubLeafletMyLexicon } from "./myLexicon"; 96 - 97 - // In the permissions collection array: 98 - collection: [ 99 - // ... existing lexicons 100 - PubLeafletMyLexicon.id, 101 - ], 102 - ``` 103 - 104 - ### 5. Regenerate Types 105 - 106 - ```bash 107 - npm run lexgen 108 - ``` 109 - 110 - ### 6. Use the Generated Types 111 - 112 - ```typescript 113 - import { PubLeafletMyLexicon } from "lexicons/api"; 114 - 115 - // Type for the record 116 - type MyRecord = PubLeafletMyLexicon.Record; 117 - 118 - // Validation 119 - const result = PubLeafletMyLexicon.validateRecord(data); 120 - if (result.success) { 121 - // result.value is typed 122 - } 123 - 124 - // Type guard 125 - if (PubLeafletMyLexicon.isRecord(data)) { 126 - // data is typed as Record 127 - } 128 - ``` 129 - 130 - ## Adding a New site.standard Lexicon 131 - 132 - ### 1. Create the JSON Definition 133 - 134 - Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): 135 - 136 - ```json 137 - { 138 - "lexicon": 1, 139 - "id": "site.standard.myType", 140 - "defs": { 141 - "main": { 142 - "type": "record", 143 - "key": "tid", 144 - "record": { 145 - "type": "object", 146 - "required": ["field1"], 147 - "properties": { 148 - "field1": { 149 - "type": "string", 150 - "maxLength": 1000 151 - } 152 - } 153 - } 154 - } 155 - } 156 - } 157 - ``` 158 - 159 - ### 2. Regenerate Types 160 - 161 - ```bash 162 - npm run lexgen 163 - ``` 164 - 165 - The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. 166 - 167 - ## Common Lexicon Patterns 168 - 169 - ### Referencing Other Lexicons 170 - 171 - ```typescript 172 - // Reference another lexicon's main def 173 - { type: "ref", ref: "pub.leaflet.publication" } 174 - 175 - // Reference a specific def within a lexicon 176 - { type: "ref", ref: "pub.leaflet.publication#theme" } 177 - 178 - // Reference within the same lexicon 179 - { type: "ref", ref: "#myDef" } 180 - ``` 181 - 182 - ### Union Types 183 - 184 - ```typescript 185 - { 186 - type: "union", 187 - refs: [ 188 - "pub.leaflet.pages.linearDocument", 189 - "pub.leaflet.pages.canvas", 190 - ], 191 - } 192 - 193 - // Open union (allows unknown types) 194 - { 195 - type: "union", 196 - closed: false, // default is true 197 - refs: ["pub.leaflet.content"], 198 - } 199 - ``` 200 - 201 - ### Blob Types (for images/files) 202 - 203 - ```typescript 204 - { 205 - type: "blob", 206 - accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] 207 - maxSize: 1000000, // bytes 208 - } 209 - ``` 210 - 211 - ### Color Types 212 - 213 - The project has color types defined: 214 - - `pub.leaflet.theme.color#rgb` / `#rgba` 215 - - `site.standard.theme.color#rgb` / `#rgba` 216 - 217 - ```typescript 218 - // In lexicons/src/theme.ts 219 - export const ColorUnion = { 220 - type: "union", 221 - refs: [ 222 - "pub.leaflet.theme.color#rgba", 223 - "pub.leaflet.theme.color#rgb", 224 - ], 225 - }; 226 - ``` 227 - 228 - ## Normalization Between Formats 229 - 230 - Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: 231 - 232 - ```typescript 233 - import { 234 - normalizeDocument, 235 - normalizePublication, 236 - isLeafletDocument, 237 - isStandardDocument, 238 - getDocumentPages, 239 - } from "lexicons/src/normalize"; 240 - 241 - // Normalize a document from either format 242 - const normalized = normalizeDocument(record); 243 - if (normalized) { 244 - // normalized is always in site.standard.document format 245 - console.log(normalized.title, normalized.site); 246 - 247 - // Get pages if content is pub.leaflet.content 248 - const pages = getDocumentPages(normalized); 249 - } 250 - 251 - // Normalize a publication 252 - const pub = normalizePublication(record); 253 - if (pub) { 254 - console.log(pub.name, pub.url); 255 - } 256 - ``` 257 - 258 - ## Handling in Appview (Firehose Consumer) 259 - 260 - When processing records from the firehose in `appview/index.ts`: 261 - 262 - ```typescript 263 - import { ids } from "lexicons/api/lexicons"; 264 - import { PubLeafletMyLexicon } from "lexicons/api"; 265 - 266 - // In filterCollections: 267 - filterCollections: [ 268 - ids.PubLeafletMyLexicon, 269 - // ... 270 - ], 271 - 272 - // In handleEvent: 273 - if (evt.collection === ids.PubLeafletMyLexicon) { 274 - if (evt.event === "create" || evt.event === "update") { 275 - let record = PubLeafletMyLexicon.validateRecord(evt.record); 276 - if (!record.success) return; 277 - 278 - // Store in database 279 - await supabase.from("my_table").upsert({ 280 - uri: evt.uri.toString(), 281 - data: record.value as Json, 282 - }); 283 - } 284 - if (evt.event === "delete") { 285 - await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); 286 - } 287 - } 288 - ``` 289 - 290 - ## Publishing Lexicons 291 - 292 - To publish lexicons to an AT Protocol PDS: 293 - 294 - ```bash 295 - npm run publish-lexicons 296 - ``` 297 - 298 - This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
-144
.claude/skills/notifications.md
··· 1 - # Notification System 2 - 3 - ## Overview 4 - 5 - Notifications are stored in the database and hydrated with related data before being rendered. The system supports multiple notification types (comments, subscriptions, etc.) that are processed in parallel. 6 - 7 - ## Key Files 8 - 9 - - **`src/notifications.ts`** - Core notification types and hydration logic 10 - - **`app/(home-pages)/notifications/NotificationList.tsx`** - Renders all notification types 11 - - **`app/(home-pages)/notifications/Notification.tsx`** - Base notification component 12 - - Individual notification components (e.g., `CommentNotification.tsx`, `FollowNotification.tsx`) 13 - 14 - ## Adding a New Notification Type 15 - 16 - ### 1. Update Notification Data Types (`src/notifications.ts`) 17 - 18 - Add your type to the `NotificationData` union: 19 - 20 - ```typescript 21 - export type NotificationData = 22 - | { type: "comment"; comment_uri: string; parent_uri?: string } 23 - | { type: "subscribe"; subscription_uri: string } 24 - | { type: "your_type"; your_field: string }; // Add here 25 - ``` 26 - 27 - Add to the `HydratedNotification` union: 28 - 29 - ```typescript 30 - export type HydratedNotification = 31 - | HydratedCommentNotification 32 - | HydratedSubscribeNotification 33 - | HydratedYourNotification; // Add here 34 - ``` 35 - 36 - ### 2. Create Hydration Function (`src/notifications.ts`) 37 - 38 - ```typescript 39 - export type HydratedYourNotification = Awaited< 40 - ReturnType<typeof hydrateYourNotifications> 41 - >[0]; 42 - 43 - async function hydrateYourNotifications(notifications: NotificationRow[]) { 44 - const yourNotifications = notifications.filter( 45 - (n): n is NotificationRow & { data: ExtractNotificationType<"your_type"> } => 46 - (n.data as NotificationData)?.type === "your_type", 47 - ); 48 - 49 - if (yourNotifications.length === 0) return []; 50 - 51 - // Fetch related data with joins 52 - const { data } = await supabaseServerClient 53 - .from("your_table") 54 - .select("*, related_table(*)") 55 - .in("uri", yourNotifications.map((n) => n.data.your_field)); 56 - 57 - return yourNotifications.map((notification) => ({ 58 - id: notification.id, 59 - recipient: notification.recipient, 60 - created_at: notification.created_at, 61 - type: "your_type" as const, 62 - your_field: notification.data.your_field, 63 - yourData: data?.find((d) => d.uri === notification.data.your_field)!, 64 - })); 65 - } 66 - ``` 67 - 68 - Add to `hydrateNotifications` parallel array: 69 - 70 - ```typescript 71 - const [commentNotifications, subscribeNotifications, yourNotifications] = await Promise.all([ 72 - hydrateCommentNotifications(notifications), 73 - hydrateSubscribeNotifications(notifications), 74 - hydrateYourNotifications(notifications), // Add here 75 - ]); 76 - 77 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...yourNotifications]; 78 - ``` 79 - 80 - ### 3. Trigger the Notification (in your action file) 81 - 82 - ```typescript 83 - import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 84 - import { v7 } from "uuid"; 85 - 86 - // When the event occurs: 87 - const recipient = /* determine who should receive it */; 88 - if (recipient !== currentUser) { 89 - const notification: Notification = { 90 - id: v7(), 91 - recipient, 92 - data: { 93 - type: "your_type", 94 - your_field: "value", 95 - }, 96 - }; 97 - await supabaseServerClient.from("notifications").insert(notification); 98 - await pingIdentityToUpdateNotification(recipient); 99 - } 100 - ``` 101 - 102 - ### 4. Create Notification Component 103 - 104 - Create a new component (e.g., `YourNotification.tsx`): 105 - 106 - ```typescript 107 - import { HydratedYourNotification } from "src/notifications"; 108 - import { Notification } from "./Notification"; 109 - 110 - export const YourNotification = (props: HydratedYourNotification) => { 111 - // Extract data from props.yourData 112 - 113 - return ( 114 - <Notification 115 - timestamp={props.created_at} 116 - href={/* link to relevant page */} 117 - icon={/* icon or avatar */} 118 - actionText={<>Message to display</>} 119 - content={/* optional additional content */} 120 - /> 121 - ); 122 - }; 123 - ``` 124 - 125 - ### 5. Update NotificationList (`NotificationList.tsx`) 126 - 127 - Import and render your notification type: 128 - 129 - ```typescript 130 - import { YourNotification } from "./YourNotification"; 131 - 132 - // In the map function: 133 - if (n.type === "your_type") { 134 - return <YourNotification key={n.id} {...n} />; 135 - } 136 - ``` 137 - 138 - ## Example: Subscribe Notifications 139 - 140 - See the implementation in: 141 - - `src/notifications.ts:88-125` - Hydration logic 142 - - `app/lish/subscribeToPublication.ts:55-68` - Trigger 143 - - `app/(home-pages)/notifications/FollowNotification.tsx` - Component 144 - - `app/(home-pages)/notifications/NotificationList.tsx:40-42` - Rendering
+64
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + Leaflet is a full-stack TypeScript web app for shared writing and social publishing, built on Bluesky (AT Protocol). Users create collaborative documents ("Leaflets") and publish them as blog posts/newsletters ("Publications") that others can follow. 8 + 9 + ## Tech Stack 10 + 11 + - **Frontend**: React 19 + Next.js 16 (App Router, Turbopack) 12 + - **Database**: PostgreSQL via Supabase + Drizzle ORM 13 + - **Real-time sync**: Replicache for optimistic updates and offline support 14 + - **Editor**: ProseMirror with Yjs CRDT 15 + - **Social**: AT Protocol (@atproto packages) for Bluesky integration 16 + - **Jobs**: Inngest for async serverless functions 17 + 18 + ## Commands 19 + 20 + ```bash 21 + npm run dev # Start Next.js dev server (port 3000) 22 + npm run start-appview-dev # Start AT Protocol appview service 23 + npm run start-feed-service-dev # Start feed subscription service 24 + npm run lint # ESLint 25 + npx tsc # TypeScript type checking (used in CI) 26 + npm run generate-db-types # Regenerate Supabase types after schema changes 27 + npm run lexgen # Regenerate AT Protocol types from lexicons 28 + ``` 29 + 30 + ## Architecture 31 + 32 + ### Data Flow 33 + 34 + 1. **Client mutations** go through Replicache (`src/replicache/mutations.ts`) for optimistic local updates 35 + 2. **Server actions** in `actions/` persist changes to Supabase 36 + 3. **CVR sync** reconciles client and server state via Replicache 37 + 38 + ### Document Model 39 + 40 + Documents are composed of **blocks** (text, image, embed, code, poll, etc.). Block components live in `components/Blocks/`. Blocks are rendered in linear lists by `components/Blocks/index.tsx` and in an xy canvas by `components/Canvas.tsx`. 41 + 42 + ### AT Protocol / Bluesky 43 + 44 + - **Lexicons** (`lexicons/`) define schemas for reading and writing records to the AT Protocol network and users' PDSs 45 + - **Appview** (`appview/`) consumes the firehose to index published content 46 + - **Feeds** (`feeds/`) provides subscription feeds for publications 47 + 48 + ### Key Directories 49 + 50 + - `app/` - Next.js App Router pages and API routes 51 + - `actions/` - Server actions (mutations, auth, subscriptions) 52 + - `components/` - React components 53 + - `src/` - Shared utilities, hooks, Replicache setup 54 + - `appview/` - AT Protocol firehose consumer for indexing 55 + - `feeds/` - Feed service for publication subscriptions 56 + - `lexicons/` - AT Protocol schema definitions 57 + - `supabase/migrations/` - Database schema 58 + 59 + ### Patterns 60 + 61 + - **Server actions**: Export async functions with `'use server'` directive, return `Result<T>` from `src/result.ts` 62 + - **Replicache mutations**: Named handlers in `src/replicache/mutations.ts`, keep server mutations idempotent 63 + - **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data 64 + - **Inngest functions**: Async jobs in `app/api/inngest/functions/`
+39
actions/publishToPublication.ts
··· 78 78 cover_image, 79 79 entitiesToDelete, 80 80 publishedAt, 81 + postPreferences, 81 82 }: { 82 83 root_entity: string; 83 84 publication_uri?: string; ··· 88 89 cover_image?: string | null; 89 90 entitiesToDelete?: string[]; 90 91 publishedAt?: string; 92 + postPreferences?: { 93 + showComments?: boolean; 94 + showMentions?: boolean; 95 + showRecommends?: boolean; 96 + } | null; 91 97 }): Promise<PublishResult> { 92 98 let identity = await getIdentityData(); 93 99 if (!identity || !identity.atp_did) { ··· 137 143 .single(); 138 144 draft = data; 139 145 existingDocUri = draft?.document; 146 + 147 + // If updating an existing document, verify the current user is the owner 148 + if (existingDocUri) { 149 + let docOwner = new AtUri(existingDocUri).host; 150 + if (docOwner !== identity.atp_did) { 151 + return { 152 + success: false, 153 + error: { 154 + type: "oauth_session_expired" as const, 155 + message: "Not the document owner", 156 + did: identity.atp_did, 157 + }, 158 + }; 159 + } 160 + } 140 161 } 141 162 142 163 // Heuristic: Remove title entities if this is the first time publishing ··· 174 195 theme: normalizedDoc.theme, 175 196 }; 176 197 } 198 + 199 + // Resolve preferences: explicit param > draft DB value 200 + const preferences = postPreferences ?? draft?.preferences; 177 201 178 202 // Extract theme for standalone documents (not for publications) 179 203 let theme: PubLeafletPublication.Theme | undefined; ··· 245 269 ...(coverImageBlob && { coverImage: coverImageBlob }), 246 270 // Include theme for standalone documents (not for publication documents) 247 271 ...(!publication_uri && theme && { theme }), 272 + ...(preferences && { 273 + preferences: { 274 + $type: "pub.leaflet.publication#preferences" as const, 275 + ...preferences, 276 + }, 277 + }), 248 278 content: { 249 279 $type: "pub.leaflet.content" as const, 250 280 pages: pagesArray, ··· 257 287 author: credentialSession.did!, 258 288 ...(publication_uri && { publication: publication_uri }), 259 289 ...(theme && { theme }), 290 + ...(preferences && { 291 + preferences: { 292 + $type: "pub.leaflet.publication#preferences" as const, 293 + ...preferences, 294 + }, 295 + }), 260 296 title: title || "Untitled", 261 297 description: description || "", 262 298 ...(tags !== undefined && { tags }), ··· 279 315 await supabaseServerClient.from("documents").upsert({ 280 316 uri: result.uri, 281 317 data: record as unknown as Json, 318 + indexed: true, 282 319 }); 283 320 284 321 if (publication_uri) { ··· 490 527 if (b.type === "bluesky-post") { 491 528 let [post] = scan.eav(b.value, "block/bluesky-post"); 492 529 if (!post || !post.data.value.post) return; 530 + let [hostFact] = scan.eav(b.value, "bluesky-post/host"); 493 531 let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 494 532 $type: ids.PubLeafletBlocksBskyPost, 495 533 postRef: { 496 534 uri: post.data.value.post.uri, 497 535 cid: post.data.value.post.cid, 498 536 }, 537 + clientHost: hostFact?.data.value, 499 538 }; 500 539 return block; 501 540 }
+18 -12
app/(home-pages)/discover/PubListing.tsx app/(home-pages)/p/[didOrHandle]/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 - import { SubscribeWithBluesky } from "app/lish/Subscribe"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 5 6 import { PubIcon } from "components/ActionBar/Publications"; 6 7 import { Separator } from "components/Layout"; 7 8 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; ··· 9 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 11 import { timeAgo } from "src/utils/timeAgo"; 11 12 12 - export const PubListing = ( 13 - props: PublicationSubscription & { 14 - resizeHeight?: boolean; 15 - }, 16 - ) => { 13 + export const PubListing = (props: PublicationSubscription) => { 17 14 let record = props.record; 18 15 let theme = usePubTheme(record?.theme); 19 16 let backgroundImage = record?.theme?.backgroundImage?.image?.ref ··· 28 25 if (!record) return null; 29 26 return ( 30 27 <BaseThemeProvider {...theme} local> 31 - <a 32 - href={record.url} 28 + <div 33 29 className={`no-underline! flex flex-row gap-2 34 30 bg-bg-leaflet 35 31 border border-border-light rounded-lg ··· 42 38 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 43 39 }} 44 40 > 41 + <a href={record.url} className="absolute inset-0" /> 45 42 <div 46 - className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg relative z-10 ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 43 + className={`flex w-full flex-col justify-center text-center pt-4 pb-3 px-3 rounded-lg relative z-10 sm:h-[200px] h-full ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 47 44 > 48 45 <div className="mx-auto pb-1"> 49 46 <PubIcon record={record} uri={props.uri} large /> ··· 51 48 52 49 <h4 className="truncate shrink-0 ">{record.name}</h4> 53 50 {record.description && ( 54 - <p className="text-secondary text-sm max-h-full overflow-hidden pb-1"> 51 + <p className="text-secondary line-clamp-1 min-h-[16px] text-sm overflow-hidden "> 55 52 {record.description} 56 53 </p> 57 54 )} 58 - <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-2"> 55 + <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-1"> 59 56 <div className="flex flex-row gap-2 items-center"> 60 57 {props.authorProfile?.handle} 61 58 </div> ··· 67 64 )} 68 65 </p> 69 66 </div> 67 + <div className="w-fit mx-auto mt-3 grow items-end flex"> 68 + <SubscribeWithBluesky 69 + compact 70 + pub_uri={props.uri} 71 + pubName={props.record.name} 72 + subscribers={props.publication_subscriptions || []} 73 + base_url={getPublicationURL({ ...props })} 74 + /> 75 + </div> 70 76 </div> 71 - </a> 77 + </div> 72 78 </BaseThemeProvider> 73 79 ); 74 80 };
-97
app/(home-pages)/discover/SortButtons.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { useState } from "react"; 4 - import { theme } from "tailwind.config"; 5 - 6 - export default function SortButtons(props: { order: string }) { 7 - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 8 - "recentlyUpdated", 9 - ); 10 - 11 - return ( 12 - <div className="flex gap-2 pt-1"> 13 - <Link href="?order=recentlyUpdated"> 14 - <SortButton selected={props.order === "recentlyUpdated"}> 15 - Recently Updated 16 - </SortButton> 17 - </Link> 18 - 19 - <Link href="?order=popular"> 20 - <SortButton selected={props.order === "popular"}>Popular</SortButton> 21 - </Link> 22 - </div> 23 - ); 24 - } 25 - 26 - const SortButton = (props: { 27 - children: React.ReactNode; 28 - selected: boolean; 29 - }) => { 30 - return ( 31 - <div className="relative"> 32 - <button 33 - style={ 34 - props.selected 35 - ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 36 - : {} 37 - } 38 - className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 39 - > 40 - {props.children} 41 - </button> 42 - {props.selected && ( 43 - <> 44 - <div className="absolute top-0 -left-2"> 45 - <GlitterBig /> 46 - </div> 47 - <div className="absolute top-4 left-0"> 48 - <GlitterSmall /> 49 - </div> 50 - <div className="absolute -top-2 -right-1"> 51 - <GlitterSmall /> 52 - </div> 53 - </> 54 - )} 55 - </div> 56 - ); 57 - }; 58 - 59 - const GlitterBig = () => { 60 - return ( 61 - <svg 62 - width="16" 63 - height="17" 64 - viewBox="0 0 16 17" 65 - fill="none" 66 - xmlns="http://www.w3.org/2000/svg" 67 - > 68 - <path 69 - d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 70 - fill={theme.colors["accent-1"]} 71 - stroke={theme.colors["bg-leaflet"]} 72 - strokeLinecap="round" 73 - strokeLinejoin="round" 74 - /> 75 - </svg> 76 - ); 77 - }; 78 - 79 - const GlitterSmall = () => { 80 - return ( 81 - <svg 82 - width="13" 83 - height="14" 84 - viewBox="0 0 13 14" 85 - fill="none" 86 - xmlns="http://www.w3.org/2000/svg" 87 - > 88 - <path 89 - d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 90 - fill={theme.colors["accent-1"]} 91 - stroke={theme.colors["bg-leaflet"]} 92 - strokeLinecap="round" 93 - strokeLinejoin="round" 94 - /> 95 - </svg> 96 - ); 97 - };
-195
app/(home-pages)/discover/SortedPublicationList.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { useState, useEffect, useRef } from "react"; 4 - import { theme } from "tailwind.config"; 5 - import { PubListing } from "./PubListing"; 6 - import useSWRInfinite from "swr/infinite"; 7 - import { getPublications, type Cursor, type Publication } from "./getPublications"; 8 - 9 - export function SortedPublicationList(props: { 10 - publications: Publication[]; 11 - order: string; 12 - nextCursor: Cursor | null; 13 - }) { 14 - let [order, setOrder] = useState(props.order); 15 - 16 - const getKey = ( 17 - pageIndex: number, 18 - previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null, 19 - ) => { 20 - // Reached the end 21 - if (previousPageData && !previousPageData.nextCursor) return null; 22 - 23 - // First page, we don't have previousPageData 24 - if (pageIndex === 0) return ["discover-publications", order, null] as const; 25 - 26 - // Add the cursor to the key 27 - return ["discover-publications", order, previousPageData?.nextCursor] as const; 28 - }; 29 - 30 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 31 - getKey, 32 - ([_, orderValue, cursor]) => { 33 - const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated"; 34 - return getPublications(orderParam, cursor); 35 - }, 36 - { 37 - fallbackData: order === props.order 38 - ? [{ publications: props.publications, nextCursor: props.nextCursor }] 39 - : undefined, 40 - revalidateFirstPage: false, 41 - }, 42 - ); 43 - 44 - const loadMoreRef = useRef<HTMLDivElement>(null); 45 - 46 - // Set up intersection observer to load more when trigger element is visible 47 - useEffect(() => { 48 - const observer = new IntersectionObserver( 49 - (entries) => { 50 - if (entries[0].isIntersecting && !isValidating) { 51 - const hasMore = data && data[data.length - 1]?.nextCursor; 52 - if (hasMore) { 53 - setSize(size + 1); 54 - } 55 - } 56 - }, 57 - { threshold: 0.1 }, 58 - ); 59 - 60 - if (loadMoreRef.current) { 61 - observer.observe(loadMoreRef.current); 62 - } 63 - 64 - return () => observer.disconnect(); 65 - }, [data, size, setSize, isValidating]); 66 - 67 - const allPublications = data ? data.flatMap((page) => page.publications) : []; 68 - 69 - return ( 70 - <div className="discoverHeader flex flex-col items-center "> 71 - <SortButtons 72 - order={order} 73 - setOrder={(o) => { 74 - const url = new URL(window.location.href); 75 - url.searchParams.set("order", o); 76 - window.history.pushState({}, "", url); 77 - setOrder(o); 78 - }} 79 - /> 80 - <div className="discoverPubList flex flex-col gap-3 pt-6 w-full relative"> 81 - {allPublications.map((pub) => ( 82 - <PubListing resizeHeight key={pub.uri} {...pub} /> 83 - ))} 84 - {/* Trigger element for loading more publications */} 85 - <div 86 - ref={loadMoreRef} 87 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 88 - aria-hidden="true" 89 - /> 90 - {isValidating && ( 91 - <div className="text-center text-tertiary py-4"> 92 - Loading more publications... 93 - </div> 94 - )} 95 - </div> 96 - </div> 97 - ); 98 - } 99 - 100 - export default function SortButtons(props: { 101 - order: string; 102 - setOrder: (order: string) => void; 103 - }) { 104 - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 105 - "recentlyUpdated", 106 - ); 107 - 108 - return ( 109 - <div className="flex gap-2 pt-1"> 110 - <SortButton 111 - selected={props.order === "recentlyUpdated"} 112 - onClick={() => props.setOrder("recentlyUpdated")} 113 - > 114 - Recently Updated 115 - </SortButton> 116 - 117 - <SortButton 118 - selected={props.order === "popular"} 119 - onClick={() => props.setOrder("popular")} 120 - > 121 - Popular 122 - </SortButton> 123 - </div> 124 - ); 125 - } 126 - 127 - const SortButton = (props: { 128 - children: React.ReactNode; 129 - onClick: () => void; 130 - selected: boolean; 131 - }) => { 132 - return ( 133 - <div className="relative"> 134 - <button 135 - onClick={props.onClick} 136 - className={`text-sm rounded-md px-[8px] font-bold py-0.5 border ${props.selected ? "border-accent-contrast bg-accent-1 text-accent-2 " : "bg-bg-page text-accent-contrast border-accent-contrast"}`} 137 - > 138 - {props.children} 139 - </button> 140 - {props.selected && ( 141 - <> 142 - <div className="absolute top-0 -left-2"> 143 - <GlitterBig /> 144 - </div> 145 - <div className="absolute top-4 left-0"> 146 - <GlitterSmall /> 147 - </div> 148 - <div className="absolute -top-2 -right-1"> 149 - <GlitterSmall /> 150 - </div> 151 - </> 152 - )} 153 - </div> 154 - ); 155 - }; 156 - 157 - const GlitterBig = () => { 158 - return ( 159 - <svg 160 - width="16" 161 - height="17" 162 - viewBox="0 0 16 17" 163 - fill="none" 164 - xmlns="http://www.w3.org/2000/svg" 165 - > 166 - <path 167 - d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 168 - fill={theme.colors["accent-1"]} 169 - stroke={theme.colors["bg-leaflet"]} 170 - strokeLinecap="round" 171 - strokeLinejoin="round" 172 - /> 173 - </svg> 174 - ); 175 - }; 176 - 177 - const GlitterSmall = () => { 178 - return ( 179 - <svg 180 - width="13" 181 - height="14" 182 - viewBox="0 0 13 14" 183 - fill="none" 184 - xmlns="http://www.w3.org/2000/svg" 185 - > 186 - <path 187 - d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 188 - fill={theme.colors["accent-1"]} 189 - stroke={theme.colors["bg-leaflet"]} 190 - strokeLinecap="round" 191 - strokeLinejoin="round" 192 - /> 193 - </svg> 194 - ); 195 - };
-133
app/(home-pages)/discover/getPublications.ts
··· 1 - "use server"; 2 - 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 - import { 5 - normalizePublicationRow, 6 - hasValidPublication, 7 - } from "src/utils/normalizeRecords"; 8 - import { deduplicateByUri } from "src/utils/deduplicateRecords"; 9 - 10 - export type Cursor = { 11 - sort_date?: string; 12 - count?: number; 13 - uri: string; 14 - }; 15 - 16 - export type Publication = Awaited< 17 - ReturnType<typeof getPublications> 18 - >["publications"][number]; 19 - 20 - export async function getPublications( 21 - order: "recentlyUpdated" | "popular" = "recentlyUpdated", 22 - cursor?: Cursor | null, 23 - ): Promise<{ publications: any[]; nextCursor: Cursor | null }> { 24 - const limit = 25; 25 - 26 - // Fetch all publications with their most recent document 27 - let { data: publications, error } = await supabaseServerClient 28 - .from("publications") 29 - .select( 30 - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 31 - ) 32 - .or( 33 - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 34 - ) 35 - .order("documents(sort_date)", { 36 - referencedTable: "documents_in_publications", 37 - ascending: false, 38 - }) 39 - .limit(1, { referencedTable: "documents_in_publications" }); 40 - 41 - if (error) { 42 - console.error("Error fetching publications:", error); 43 - return { publications: [], nextCursor: null }; 44 - } 45 - 46 - // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 47 - const dedupedPublications = deduplicateByUri(publications || []); 48 - 49 - // Filter out publications without documents 50 - const allPubs = dedupedPublications.filter( 51 - (pub) => pub.documents_in_publications.length > 0, 52 - ); 53 - 54 - // Sort on the server 55 - allPubs.sort((a, b) => { 56 - if (order === "popular") { 57 - const aCount = a.publication_subscriptions[0]?.count || 0; 58 - const bCount = b.publication_subscriptions[0]?.count || 0; 59 - if (bCount !== aCount) { 60 - return bCount - aCount; 61 - } 62 - // Secondary sort by uri for stability 63 - return b.uri.localeCompare(a.uri); 64 - } else { 65 - // recentlyUpdated 66 - const aDate = new Date( 67 - a.documents_in_publications[0]?.documents?.sort_date || 0, 68 - ).getTime(); 69 - const bDate = new Date( 70 - b.documents_in_publications[0]?.documents?.sort_date || 0, 71 - ).getTime(); 72 - if (bDate !== aDate) { 73 - return bDate - aDate; 74 - } 75 - // Secondary sort by uri for stability 76 - return b.uri.localeCompare(a.uri); 77 - } 78 - }); 79 - 80 - // Find cursor position and slice 81 - let startIndex = 0; 82 - if (cursor) { 83 - startIndex = allPubs.findIndex((pub) => { 84 - if (order === "popular") { 85 - const pubCount = pub.publication_subscriptions[0]?.count || 0; 86 - // Find first pub after cursor 87 - return ( 88 - pubCount < (cursor.count || 0) || 89 - (pubCount === cursor.count && pub.uri < cursor.uri) 90 - ); 91 - } else { 92 - const pubDate = pub.documents_in_publications[0]?.documents?.sort_date || ""; 93 - // Find first pub after cursor 94 - return ( 95 - pubDate < (cursor.sort_date || "") || 96 - (pubDate === cursor.sort_date && pub.uri < cursor.uri) 97 - ); 98 - } 99 - }); 100 - // If not found, we're at the end 101 - if (startIndex === -1) { 102 - return { publications: [], nextCursor: null }; 103 - } 104 - } 105 - 106 - // Get the page 107 - const page = allPubs.slice(startIndex, startIndex + limit); 108 - 109 - // Normalize publication records 110 - const normalizedPage = page 111 - .map(normalizePublicationRow) 112 - .filter(hasValidPublication); 113 - 114 - // Create next cursor based on last item in normalizedPage 115 - const lastItem = normalizedPage[normalizedPage.length - 1]; 116 - const nextCursor = 117 - normalizedPage.length > 0 && startIndex + limit < allPubs.length 118 - ? order === "recentlyUpdated" 119 - ? { 120 - sort_date: lastItem.documents_in_publications[0]?.documents?.sort_date, 121 - uri: lastItem.uri, 122 - } 123 - : { 124 - count: lastItem.publication_subscriptions[0]?.count || 0, 125 - uri: lastItem.uri, 126 - } 127 - : null; 128 - 129 - return { 130 - publications: normalizedPage, 131 - nextCursor, 132 - }; 133 - }
-53
app/(home-pages)/discover/page.tsx
··· 1 - import Link from "next/link"; 2 - import { SortedPublicationList } from "./SortedPublicationList"; 3 - import { Metadata } from "next"; 4 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 5 - import { getPublications } from "./getPublications"; 6 - 7 - export const metadata: Metadata = { 8 - title: "Leaflet Discover", 9 - description: "Explore publications on Leaflet ✨ Or make your own!", 10 - }; 11 - 12 - export default async function Discover(props: { 13 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 14 - }) { 15 - let order = ((await props.searchParams).order as string) || "recentlyUpdated"; 16 - 17 - return ( 18 - <DashboardLayout 19 - id="discover" 20 - currentPage="discover" 21 - defaultTab="default" 22 - actions={null} 23 - tabs={{ 24 - default: { 25 - controls: null, 26 - content: <DiscoverContent order={order} />, 27 - }, 28 - }} 29 - /> 30 - ); 31 - } 32 - 33 - const DiscoverContent = async (props: { order: string }) => { 34 - const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated"; 35 - let { publications, nextCursor } = await getPublications(orderValue); 36 - 37 - return ( 38 - <div className="max-w-prose mx-auto w-full"> 39 - <div className="discoverHeader flex flex-col items-center text-center pt-2 px-4"> 40 - <h1>Discover</h1> 41 - <p className="text-lg text-secondary italic mb-2"> 42 - Explore publications on Leaflet ✨ Or{" "} 43 - <Link href="/lish/createPub">make your own</Link>! 44 - </p> 45 - </div> 46 - <SortedPublicationList 47 - publications={publications} 48 - order={props.order} 49 - nextCursor={nextCursor} 50 - /> 51 - </div> 52 - ); 53 - };
+9 -10
app/(home-pages)/home/Actions/AccountSettings.tsx
··· 1 1 "use client"; 2 2 3 + import { useState } from "react"; 4 + import { mutate } from "swr"; 3 5 import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { mutate } from "swr"; 5 - import { AccountSmall } from "components/Icons/AccountSmall"; 6 - import { LogoutSmall } from "components/Icons/LogoutSmall"; 7 6 import { Popover } from "components/Popover"; 7 + import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter"; 8 + import { useIsMobile } from "src/hooks/isMobile"; 9 + import { PaintSmall } from "components/Icons/PaintSmall"; 8 10 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 9 - import { SpeedyLink } from "components/SpeedyLink"; 10 11 import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 - import { useState } from "react"; 12 - import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter"; 13 - import { useIsMobile } from "src/hooks/isMobile"; 12 + import { LogoutSmall } from "components/Icons/LogoutSmall"; 14 13 import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 15 14 import { Modal } from "components/Modal"; 16 15 import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; ··· 25 24 return ( 26 25 <Popover 27 26 asChild 28 - onOpenChange={() => setState("menu")} 29 27 side={isMobile ? "top" : "right"} 30 28 align={isMobile ? "center" : "start"} 31 - className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 32 - trigger={<ActionButton icon=<AccountSmall /> label="Settings" />} 29 + className={`w-xs bg-white!`} 30 + arrowFill="bg-white" 31 + trigger={<ActionButton smallOnMobile icon=<PaintSmall /> label="Theme" />} 33 32 > 34 33 {state === "general" ? ( 35 34 <GeneralSettings backToMenu={() => setState("menu")} />
+1 -5
app/(home-pages)/home/Actions/Actions.tsx
··· 13 13 return ( 14 14 <> 15 15 <CreateNewLeafletButton /> 16 - {identity ? ( 17 - <AccountSettings entityID={rootEntity} /> 18 - ) : ( 19 - <LoginActionButton /> 20 - )} 16 + {identity && <AccountSettings entityID={rootEntity} />} 21 17 </> 22 18 ); 23 19 };
+2 -1
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 26 26 <ActionButton 27 27 id="new-leaflet-button" 28 28 primary 29 - icon=<AddTiny className="m-1 shrink-0" /> 29 + icon=<AddTiny className="sm:m-1 shrink-0 sm:scale-100 scale-75" /> 30 30 label="New" 31 + smallOnMobile 31 32 /> 32 33 } 33 34 >
+55 -79
app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx
··· 1 1 "use client"; 2 - 3 2 import { PubListEmptyIllo } from "components/ActionBar/Publications"; 4 - import { ButtonPrimary } from "components/Buttons"; 5 - import { AddSmall } from "components/Icons/AddSmall"; 6 - import { Link } from "react-aria-components"; 7 - import { DiscoverIllo } from "./DiscoverIllo"; 3 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 8 4 import { WelcomeToLeafletIllo } from "./WelcomeToLeafletIllo"; 9 - import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 10 - import { PublishSmall } from "components/Icons/PublishSmall"; 11 5 import { createNewLeaflet } from "actions/createNewLeaflet"; 12 6 import { useIsMobile } from "src/hooks/isMobile"; 7 + import { SpeedyLink } from "components/SpeedyLink"; 13 8 14 9 export function HomeEmptyState() { 15 - let isMobile = useIsMobile(); 16 10 return ( 17 - <div className="flex flex-col gap-4 font-bold"> 18 - <div className="container p-2 flex gap-4"> 19 - <div className="w-[72px]"> 20 - <WelcomeToLeafletIllo /> 21 - </div> 22 - <div className="flex flex-col grow"> 23 - <h3 className="text-xl font-semibold pt-2">Leaflet</h3> 24 - {/*<h3>A platform for social publishing.</h3>*/} 25 - <div className="font-normal text-tertiary italic"> 26 - Write and share delightful documents! 27 - </div> 28 - <ButtonPrimary 29 - className="!text-lg my-3" 30 - onClick={async () => { 31 - let openNewLeaflet = (id: string) => { 32 - if (isMobile) { 33 - window.location.href = `/${id}?focusFirstBlock`; 34 - } else { 35 - window.open(`/${id}?focusFirstBlock`, "_blank"); 36 - } 37 - }; 38 - 39 - let id = await createNewLeaflet({ 40 - pageType: "doc", 41 - redirectUser: false, 42 - }); 43 - openNewLeaflet(id); 44 - }} 45 - > 46 - <AddSmall /> Write a Doc! 47 - </ButtonPrimary> 48 - </div> 49 - </div> 50 - <div className="flex gap-2 w-full items-center text-tertiary font-normal italic"> 51 - <hr className="border-border w-full" /> 11 + <div className="flex flex-col gap-4 sm:flex-row"> 12 + <PublicationBanner /> 13 + <div className="flex sm:flex-col flex-row gap-2 sm:w-fit w-full items-center text-tertiary font-normal italic"> 14 + <hr className="border-border grow w-full sm:w-px h-px sm:h-full border-l" /> 52 15 <div>or</div> 53 - <hr className="border-border w-full" /> 16 + <hr className="border-border grow w-full sm:w-px h-px sm:h-full border-l" /> 54 17 </div> 55 - 56 - <PublicationBanner /> 57 - <DiscoverBanner /> 58 - <div className="text-tertiary italic text-sm font-normal -mt-2"> 59 - Right now docs and publications are separate. Soon you'll be able to add 60 - docs to pubs! 61 - </div> 18 + <DocBanner /> 62 19 </div> 63 20 ); 64 21 } 65 22 66 - export const PublicationBanner = (props: { small?: boolean }) => { 23 + let bannerStyles = 24 + "flex flex-row sm:flex-col py-4 px-4 sm:items-center items-start gap-4"; 25 + 26 + const PublicationBanner = () => { 67 27 return ( 68 - <div 69 - className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 70 - > 71 - {props.small ? ( 72 - <PublishSmall className="shrink-0 text-accent-contrast" /> 73 - ) : ( 28 + <div className={`accent-container sm:basis-2/3 ${bannerStyles}`}> 29 + <div className="my-auto flex flex-row sm:flex-col gap-4"> 74 30 <div className="w-[64px] mx-auto"> 75 31 <PubListEmptyIllo /> 76 32 </div> 77 - )} 78 - <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 79 - <Link href={"/lish/createPub"} className="font-bold"> 80 - Start a Publication 81 - </Link>{" "} 82 - and blog in the Atmosphere 33 + <div className={`flex flex-col sm:text-center text-left w-full`}> 34 + <h3>Create a Publication!</h3> 35 + <div className="mb-2 text-tertiary"> 36 + You can decide to share or publish it later. 37 + </div> 38 + <SpeedyLink href="/lish/createPub" className="sm:mx-auto mx-0 "> 39 + <ButtonPrimary>Create a Publication</ButtonPrimary> 40 + </SpeedyLink> 41 + </div> 83 42 </div> 84 43 </div> 85 44 ); 86 45 }; 87 46 88 - export const DiscoverBanner = (props: { small?: boolean }) => { 47 + const DocBanner = () => { 48 + let isMobile = useIsMobile(); 49 + 89 50 return ( 90 - <div 91 - className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 92 - > 93 - {props.small ? ( 94 - <DiscoverSmall className="shrink-0 text-accent-contrast" /> 95 - ) : ( 96 - <div className="w-[64px] mx-auto"> 97 - <DiscoverIllo /> 51 + <div className={`text-sm sm:basis-1/3 py-0! sm:py-4! ${bannerStyles}`}> 52 + <div className="w-[48px] mx-auto"> 53 + <WelcomeToLeafletIllo /> 54 + </div> 55 + 56 + <div className={`grow flex flex-col sm:text-center text-left w-full`}> 57 + <h4>Just write something</h4> 58 + <div className="mb-2 text-tertiary"> 59 + You can decide to share or publish it later. 98 60 </div> 99 - )} 100 - <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 101 - <Link href={"/discover"} className="font-bold"> 102 - Explore Publications 103 - </Link>{" "} 104 - on art, tech, games, music & more! 61 + <ButtonSecondary 62 + className="sm:mx-auto mx-0" 63 + onClick={async () => { 64 + let openNewLeaflet = (id: string) => { 65 + if (isMobile) { 66 + window.location.href = `/${id}?focusFirstBlock`; 67 + } else { 68 + window.open(`/${id}?focusFirstBlock`, "_blank"); 69 + } 70 + }; 71 + 72 + let id = await createNewLeaflet({ 73 + pageType: "doc", 74 + redirectUser: false, 75 + }); 76 + openNewLeaflet(id); 77 + }} 78 + > 79 + New Doc! 80 + </ButtonSecondary> 105 81 </div> 106 82 </div> 107 83 );
+2 -2
app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx
··· 3 3 export const WelcomeToLeafletIllo = () => { 4 4 return ( 5 5 <svg 6 - width="73" 7 - height="68" 6 + width="48" 7 + height="44" 8 8 viewBox="0 0 73 68" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg"
+3 -10
app/(home-pages)/home/HomeLayout.tsx
··· 23 23 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 24 24 import { useState } from "react"; 25 25 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 26 - import { 27 - DiscoverBanner, 28 - HomeEmptyState, 29 - PublicationBanner, 30 - } from "./HomeEmpty/HomeEmpty"; 26 + import { HomeEmptyState } from "./HomeEmpty/HomeEmpty"; 31 27 32 28 export type Leaflet = { 33 29 added_at: string; ··· 103 99 ), 104 100 }, 105 101 }} 102 + pageTitle={"Home"} 106 103 /> 107 104 ); 108 105 }; ··· 170 167 showPreview 171 168 /> 172 169 <div className="spacer h-4 w-full bg-transparent shrink-0 " /> 173 - 174 - {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === 175 - 0 && <PublicationBanner small />} 176 - <DiscoverBanner small /> 177 170 </> 178 171 ); 179 172 } ··· 204 197 className={` 205 198 leafletList 206 199 w-full 207 - ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 pt-2"} `} 200 + ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2"} `} 208 201 > 209 202 {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 210 203 <ReplicacheProvider
+3
app/(home-pages)/layout.tsx
··· 1 1 import { getIdentityData } from "actions/getIdentityData"; 2 2 import { EntitySetProvider } from "components/EntitySetProvider"; 3 + import { NavStateTracker } from "components/NavStateTracker"; 3 4 import { 4 5 ThemeProvider, 5 6 ThemeBackgroundProvider, ··· 13 14 if (!identityData?.home_leaflet) 14 15 return ( 15 16 <> 17 + <NavStateTracker /> 16 18 <ThemeProvider entityID={""}>{props.children}</ThemeProvider> 17 19 </> 18 20 ); ··· 34 36 > 35 37 <ThemeProvider entityID={root_entity}> 36 38 <ThemeBackgroundProvider entityID={root_entity}> 39 + <NavStateTracker /> 37 40 {props.children} 38 41 </ThemeBackgroundProvider> 39 42 </ThemeProvider>
+1
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 47 47 ), 48 48 }, 49 49 }} 50 + pageTitle="Looseleafs" 50 51 /> 51 52 ); 52 53 };
+2 -5
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
··· 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedBskyPostEmbedNotification } from "src/notifications"; 4 4 import { AtUri } from "@atproto/api"; 5 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 5 6 6 7 export const BskyPostEmbedNotification = ( 7 8 props: HydratedBskyPostEmbedNotification, ··· 11 12 12 13 if (!docRecord) return null; 13 14 14 - const docUri = new AtUri(props.document.uri); 15 - const rkey = docUri.rkey; 16 - const did = docUri.host; 17 - 18 - const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; 15 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 19 16 20 17 const embedder = props.documentCreatorHandle 21 18 ? `@${props.documentCreatorHandle}`
+4 -6
app/(home-pages)/notifications/CommentMentionNotification.tsx
··· 8 8 Notification, 9 9 } from "./Notification"; 10 10 import { AtUri } from "@atproto/api"; 11 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 11 12 12 13 export const CommentMentionNotification = ( 13 14 props: HydratedCommentMentionNotification, ··· 19 20 const profileRecord = props.commentData.bsky_profiles 20 21 ?.record as AppBskyActorProfile.Record; 21 22 const pubRecord = props.normalizedPublication; 22 - const docUri = new AtUri(props.commentData.documents?.uri!); 23 - const rkey = docUri.rkey; 24 - const did = docUri.host; 25 23 26 - const href = pubRecord 27 - ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 28 - : `/p/${did}/${rkey}?interactionDrawer=comments`; 24 + const href = 25 + getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + 26 + "?interactionDrawer=comments"; 29 27 30 28 const commenter = props.commenterHandle 31 29 ? `@${props.commenterHandle}`
+4 -6
app/(home-pages)/notifications/CommentNotication.tsx
··· 10 10 Notification, 11 11 } from "./Notification"; 12 12 import { AtUri } from "@atproto/api"; 13 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 13 14 14 15 export const CommentNotification = (props: HydratedCommentNotification) => { 15 16 const docRecord = props.normalizedDocument; ··· 24 25 props.commentData.bsky_profiles?.handle || 25 26 "Someone"; 26 27 const pubRecord = props.normalizedPublication; 27 - const docUri = new AtUri(props.commentData.documents?.uri!); 28 - const rkey = docUri.rkey; 29 - const did = docUri.host; 30 28 31 - const href = pubRecord 32 - ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 33 - : `/p/${did}/${rkey}?interactionDrawer=comments`; 29 + const href = 30 + getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + 31 + "?interactionDrawer=comments"; 34 32 35 33 return ( 36 34 <Notification
+2 -7
app/(home-pages)/notifications/MentionNotification.tsx
··· 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedMentionNotification } from "src/notifications"; 4 4 import { AtUri } from "@atproto/api"; 5 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 5 6 6 7 export const MentionNotification = (props: HydratedMentionNotification) => { 7 8 const docRecord = props.normalizedDocument; ··· 9 10 10 11 if (!docRecord) return null; 11 12 12 - const docUri = new AtUri(props.document.uri); 13 - const rkey = docUri.rkey; 14 - const did = docUri.host; 15 - 16 - const href = pubRecord 17 - ? `${pubRecord.url}/${rkey}` 18 - : `/p/${did}/${rkey}`; 13 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 19 14 20 15 let actionText: React.ReactNode; 21 16 let mentionedItemName: string | undefined;
+4
app/(home-pages)/notifications/NotificationList.tsx
··· 11 11 import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; 12 12 import { MentionNotification } from "./MentionNotification"; 13 13 import { CommentMentionNotification } from "./CommentMentionNotification"; 14 + import { RecommendNotification } from "./RecommendNotification"; 14 15 15 16 export function NotificationList({ 16 17 notifications, ··· 57 58 } 58 59 if (n.type === "comment_mention") { 59 60 return <CommentMentionNotification key={n.id} {...n} />; 61 + } 62 + if (n.type === "recommend") { 63 + return <RecommendNotification key={n.id} {...n} />; 60 64 } 61 65 })} 62 66 </div>
+2 -6
app/(home-pages)/notifications/QuoteNotification.tsx
··· 3 3 import { HydratedQuoteNotification } from "src/notifications"; 4 4 import { AtUri } from "@atproto/api"; 5 5 import { Avatar } from "components/Avatar"; 6 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 6 7 7 8 export const QuoteNotification = (props: HydratedQuoteNotification) => { 8 9 const postView = props.bskyPost.post_view as any; ··· 13 14 14 15 if (!docRecord) return null; 15 16 16 - const docUri = new AtUri(props.document.uri); 17 - const rkey = docUri.rkey; 18 - const did = docUri.host; 19 17 const postText = postView.record?.text || ""; 20 18 21 - const href = pubRecord 22 - ? `${pubRecord.url}/${rkey}` 23 - : `/p/${did}/${rkey}`; 19 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 24 20 25 21 return ( 26 22 <Notification
+45
app/(home-pages)/notifications/RecommendNotification.tsx
··· 1 + import { ContentLayout, Notification } from "./Notification"; 2 + import { HydratedRecommendNotification } from "src/notifications"; 3 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 4 + import { AppBskyActorProfile } from "lexicons/api"; 5 + import { Avatar } from "components/Avatar"; 6 + import { AtUri } from "@atproto/api"; 7 + import { RecommendTinyFilled } from "components/Icons/RecommendTiny"; 8 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 9 + 10 + export const RecommendNotification = ( 11 + props: HydratedRecommendNotification, 12 + ) => { 13 + const profileRecord = props.recommendData?.identities?.bsky_profiles 14 + ?.record as AppBskyActorProfile.Record; 15 + const displayName = 16 + profileRecord?.displayName || 17 + props.recommendData?.identities?.bsky_profiles?.handle || 18 + "Someone"; 19 + const docRecord = props.normalizedDocument; 20 + const pubRecord = props.normalizedPublication; 21 + const avatarSrc = 22 + profileRecord?.avatar?.ref && 23 + blobRefToSrc( 24 + profileRecord.avatar.ref, 25 + props.recommendData?.recommender_did || "", 26 + ); 27 + 28 + if (!docRecord) return null; 29 + 30 + const href = getDocumentURL(docRecord, props.document.uri, pubRecord); 31 + 32 + return ( 33 + <Notification 34 + timestamp={props.created_at} 35 + href={href} 36 + icon={<RecommendTinyFilled />} 37 + actionText={<>{displayName} recommended your post</>} 38 + content={ 39 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 40 + {null} 41 + </ContentLayout> 42 + } 43 + /> 44 + ); 45 + };
+4 -6
app/(home-pages)/notifications/ReplyNotification.tsx
··· 10 10 import { PubLeafletComment } from "lexicons/api"; 11 11 import { AppBskyActorProfile, AtUri } from "@atproto/api"; 12 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 13 14 14 15 export const ReplyNotification = (props: HydratedCommentNotification) => { 15 16 const docRecord = props.normalizedDocument; ··· 32 33 props.parentData?.bsky_profiles?.handle || 33 34 "Someone"; 34 35 35 - const docUri = new AtUri(props.commentData.documents?.uri!); 36 - const rkey = docUri.rkey; 37 - const did = docUri.host; 38 36 const pubRecord = props.normalizedPublication; 39 37 40 - const href = pubRecord 41 - ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 42 - : `/p/${did}/${rkey}?interactionDrawer=comments`; 38 + const href = 39 + getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + 40 + "?interactionDrawer=comments"; 43 41 44 42 return ( 45 43 <Notification
+1 -1
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
··· 68 68 } 69 69 70 70 return ( 71 - <div className="flex flex-col gap-3 text-left relative"> 71 + <div className="profilePosts h-full flex py-4 flex-col gap-3 text-left relative"> 72 72 {allPosts.map((post) => ( 73 73 <PostListing key={post.documents.uri} {...post} /> 74 74 ))}
+3 -2
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 43 43 </div> 44 44 ); 45 45 46 + 46 47 return ( 47 48 <div 48 49 className={`profileHeader flex flex-col relative `} ··· 73 74 </pre> 74 75 </div> 75 76 76 - <div className="profilePublicationCards w-full overflow-x-scroll"> 77 + <div className="profilePubCardContainer w-full overflow-x-scroll"> 77 78 <div 78 - className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 79 + className={`profilePubCards grid grid-flow-col gap-2 mx-auto w-fit ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 79 80 > 80 81 {props.publications.map((p) => ( 81 82 <PublicationCard key={p.uri} record={p.record} uri={p.uri} />
+2 -2
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
··· 11 11 ${ 12 12 cardBorderHidden 13 13 ? "" 14 - : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page px-3 sm:px-4" 15 15 } 16 16 max-w-prose mx-auto w-full 17 - flex flex-col 17 + flex flex-col pb-3 18 18 text-center 19 19 `} 20 20 >
+1 -1
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
··· 41 41 const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 42 42 43 43 return ( 44 - <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3 pt-6"> 44 + <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 pt-6"> 45 45 <div 46 46 style={ 47 47 scrollPosWithinTabContent < 20
+1 -1
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 85 85 } 86 86 87 87 return ( 88 - <div className="flex flex-col gap-2 text-left relative"> 88 + <div className="flex flex-col gap-2 py-4 text-left relative"> 89 89 {allComments.map((comment) => ( 90 90 <CommentItem key={comment.uri} comment={comment} /> 91 91 ))}
+15 -12
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 26 26 `*, 27 27 comments_on_documents(count), 28 28 document_mentions_in_bsky(count), 29 + recommends_on_documents(count), 29 30 documents_in_publications(publications(*))`, 30 31 ) 31 32 .like("uri", `at://${did}/%`) ··· 39 40 ); 40 41 } 41 42 42 - let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = await Promise.all([ 43 - query, 44 - supabaseServerClient 45 - .from("publications") 46 - .select("*") 47 - .eq("identity_did", did), 48 - supabaseServerClient 49 - .from("bsky_profiles") 50 - .select("handle") 51 - .eq("did", did) 52 - .single(), 53 - ]); 43 + let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = 44 + await Promise.all([ 45 + query, 46 + supabaseServerClient 47 + .from("publications") 48 + .select("*") 49 + .eq("identity_did", did), 50 + supabaseServerClient 51 + .from("bsky_profiles") 52 + .select("handle") 53 + .eq("did", did) 54 + .single(), 55 + ]); 54 56 55 57 // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 56 58 const docs = deduplicateByUriOrdered(rawDocs || []); ··· 82 84 sort_date: doc.sort_date, 83 85 comments_on_documents: doc.comments_on_documents, 84 86 document_mentions_in_bsky: doc.document_mentions_in_bsky, 87 + recommends_on_documents: doc.recommends_on_documents, 85 88 }, 86 89 }; 87 90
+2 -3
app/(home-pages)/p/[didOrHandle]/layout.tsx
··· 82 82 id="profile" 83 83 defaultTab="default" 84 84 currentPage="profile" 85 + profileDid={did} 85 86 actions={null} 86 87 tabs={{ 87 88 default: { ··· 93 94 publications={publications || []} 94 95 /> 95 96 <ProfileTabs didOrHandle={params.didOrHandle} /> 96 - <div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col"> 97 - {props.children} 98 - </div> 97 + <>{props.children}</> 99 98 </ProfileLayout> 100 99 ), 101 100 },
+2 -2
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
··· 2 2 3 3 import { useEffect, useRef } from "react"; 4 4 import useSWRInfinite from "swr/infinite"; 5 - import { PubListing } from "app/(home-pages)/discover/PubListing"; 5 + import { PubListing } from "app/(home-pages)/p/[didOrHandle]/PubListing"; 6 6 import { 7 7 getSubscriptions, 8 8 type PublicationSubscription, ··· 82 82 83 83 return ( 84 84 <div className="relative"> 85 - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 85 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3 py-4"> 86 86 {allSubscriptions.map((sub) => ( 87 87 <PubListing key={sub.uri} {...sub} /> 88 88 ))}
+31
app/(home-pages)/page.tsx
··· 1 + import { cookies } from "next/headers"; 2 + import ReaderLayout from "./reader/layout"; 3 + import ReaderPage from "./reader/page"; 4 + import HomePage from "./home/page"; 5 + 6 + export default async function RootPage() { 7 + const cookieStore = await cookies(); 8 + const hasAuth = 9 + cookieStore.has("auth_token") || 10 + cookieStore.has("external_auth_token"); 11 + 12 + if (!hasAuth) { 13 + return ( 14 + <ReaderLayout> 15 + <ReaderPage /> 16 + </ReaderLayout> 17 + ); 18 + } 19 + 20 + const navState = cookieStore.get("nav-state")?.value; 21 + 22 + if (navState === "reader") { 23 + return ( 24 + <ReaderLayout> 25 + <ReaderPage /> 26 + </ReaderLayout> 27 + ); 28 + } 29 + 30 + return <HomePage />; 31 + }
+13
app/(home-pages)/reader/FeedSkeleton.tsx
··· 1 + export function FeedSkeleton() { 2 + return ( 3 + <div className="flex flex-col gap-6 w-full animate-pulse"> 4 + {[...Array(3)].map((_, i) => ( 5 + <div key={i} className="flex flex-col gap-3 p-2"> 6 + <div className="h-4 bg-border-light rounded w-1/3" /> 7 + <div className="h-3 bg-border-light rounded w-2/3" /> 8 + <div className="h-3 bg-border-light rounded w-1/2" /> 9 + </div> 10 + ))} 11 + </div> 12 + ); 13 + }
+49
app/(home-pages)/reader/GlobalContent.tsx
··· 1 + "use client"; 2 + import { use } from "react"; 3 + import useSWR from "swr"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import { PostListing } from "components/PostListing"; 6 + import type { Post } from "./getReaderFeed"; 7 + import { 8 + DesktopInteractionPreviewDrawer, 9 + MobileInteractionPreviewDrawer, 10 + } from "./InteractionDrawers"; 11 + 12 + export const GlobalContent = (props: { 13 + promise: Promise<{ posts: Post[] }>; 14 + }) => { 15 + const initialData = use(props.promise); 16 + 17 + const { data } = useSWR( 18 + "hot_feed", 19 + async () => { 20 + const res = await callRPC("get_hot_feed", {}); 21 + return res as unknown as { posts: Post[] }; 22 + }, 23 + { 24 + fallbackData: { posts: initialData.posts }, 25 + }, 26 + ); 27 + 28 + const posts = data?.posts ?? []; 29 + 30 + if (posts.length === 0) { 31 + return ( 32 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 33 + Nothing trending right now. Check back soon! 34 + </div> 35 + ); 36 + } 37 + 38 + return ( 39 + <div className="flex flex-row gap-6 w-full"> 40 + <div className="flex flex-col gap-8 w-full"> 41 + {posts.map((p) => ( 42 + <PostListing {...p} key={p.documents.uri} /> 43 + ))} 44 + </div> 45 + <DesktopInteractionPreviewDrawer /> 46 + <MobileInteractionPreviewDrawer /> 47 + </div> 48 + ); 49 + };
+109
app/(home-pages)/reader/InteractionDrawers.tsx
··· 1 + "use client"; 2 + import { ButtonPrimary } from "components/Buttons"; 3 + import { 4 + SelectedPostListing, 5 + useSelectedPostListing, 6 + } from "src/useSelectedPostState"; 7 + import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments"; 8 + import { CloseTiny } from "components/Icons/CloseTiny"; 9 + import { SpeedyLink } from "components/SpeedyLink"; 10 + import { GoToArrow } from "components/Icons/GoToArrow"; 11 + import { DotLoader } from "components/utils/DotLoader"; 12 + import { ReaderMentionsContent } from "./ReaderMentionsContent"; 13 + import { callRPC } from "app/api/rpc/client"; 14 + import useSWR from "swr"; 15 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 16 + 17 + export const MobileInteractionPreviewDrawer = () => { 18 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 19 + 20 + return ( 21 + <div 22 + className={`z-20 fixed bottom-0 left-0 right-0 border border-border-light shrink-0 w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`} 23 + > 24 + <PreviewDrawerContent selectedPost={selectedPost} /> 25 + </div> 26 + ); 27 + }; 28 + 29 + export const DesktopInteractionPreviewDrawer = () => { 30 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 31 + 32 + return ( 33 + <div 34 + className={`hidden md:block border border-border-light shrink-0 w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`} 35 + > 36 + <PreviewDrawerContent selectedPost={selectedPost} /> 37 + </div> 38 + ); 39 + }; 40 + 41 + const PreviewDrawerContent = (props: { 42 + selectedPost: SelectedPostListing | null; 43 + }) => { 44 + const documentUri = props.selectedPost?.document_uri || null; 45 + const drawer = props.selectedPost?.drawer || null; 46 + 47 + const { data, isLoading } = useSWR( 48 + documentUri ? ["get_document_interactions", documentUri] : null, 49 + async () => { 50 + const res = await callRPC("get_document_interactions", { 51 + document_uri: documentUri!, 52 + }); 53 + return res; 54 + }, 55 + { keepPreviousData: false }, 56 + ); 57 + 58 + if (!props.selectedPost || !props.selectedPost.document) return null; 59 + 60 + const postUrl = getDocumentURL( 61 + props.selectedPost.document, 62 + props.selectedPost.document_uri, 63 + props.selectedPost.publication, 64 + ); 65 + 66 + const drawerTitle = 67 + drawer === "quotes" 68 + ? `Mentions of ${props.selectedPost.document.title}` 69 + : `Comments for ${props.selectedPost.document.title}`; 70 + 71 + return ( 72 + <> 73 + <div className="w-full text-sm text-tertiary flex justify-between pt-3 gap-3"> 74 + <div className="truncate min-w-0 grow">{drawerTitle}</div> 75 + <button 76 + className="text-tertiary" 77 + onClick={() => 78 + useSelectedPostListing.getState().setSelectedPostListing(null) 79 + } 80 + > 81 + <CloseTiny /> 82 + </button> 83 + </div> 84 + <SpeedyLink className="shrink-0 flex gap-1 items-center" href={postUrl}> 85 + <ButtonPrimary fullWidth compact className="text-sm! mt-1"> 86 + See Full Post <GoToArrow /> 87 + </ButtonPrimary> 88 + </SpeedyLink> 89 + {isLoading ? ( 90 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 91 + <span>loading</span> 92 + <DotLoader /> 93 + </div> 94 + ) : drawer === "quotes" ? ( 95 + <div className="mt-3"> 96 + <ReaderMentionsContent 97 + quotesAndMentions={data?.quotesAndMentions || []} 98 + /> 99 + </div> 100 + ) : ( 101 + <CommentsDrawerContent 102 + noCommentBox 103 + document_uri={props.selectedPost.document_uri} 104 + comments={data?.comments || []} 105 + /> 106 + )} 107 + </> 108 + ); 109 + };
+93
app/(home-pages)/reader/NewContent.tsx
··· 1 + "use client"; 2 + 3 + import { use } from "react"; 4 + import type { Cursor, Post } from "./getReaderFeed"; 5 + import useSWRInfinite from "swr/infinite"; 6 + import { getNewFeed } from "./getNewFeed"; 7 + import { useEffect, useRef } from "react"; 8 + import { PostListing } from "components/PostListing"; 9 + import { 10 + DesktopInteractionPreviewDrawer, 11 + MobileInteractionPreviewDrawer, 12 + } from "./InteractionDrawers"; 13 + 14 + export const NewContent = (props: { 15 + promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; 16 + }) => { 17 + const { posts, nextCursor } = use(props.promise); 18 + 19 + const getKey = ( 20 + pageIndex: number, 21 + previousPageData: { 22 + posts: Post[]; 23 + nextCursor: Cursor | null; 24 + } | null, 25 + ) => { 26 + if (previousPageData && !previousPageData.nextCursor) return null; 27 + if (pageIndex === 0) return ["new-feed", null] as const; 28 + return ["new-feed", previousPageData?.nextCursor] as const; 29 + }; 30 + 31 + const { data, size, setSize, isValidating } = useSWRInfinite( 32 + getKey, 33 + ([_, cursor]) => getNewFeed(cursor), 34 + { 35 + fallbackData: [{ posts, nextCursor }], 36 + revalidateFirstPage: false, 37 + }, 38 + ); 39 + 40 + const loadMoreRef = useRef<HTMLDivElement>(null); 41 + 42 + useEffect(() => { 43 + const observer = new IntersectionObserver( 44 + (entries) => { 45 + if (entries[0].isIntersecting && !isValidating) { 46 + const hasMore = data && data[data.length - 1]?.nextCursor; 47 + if (hasMore) { 48 + setSize(size + 1); 49 + } 50 + } 51 + }, 52 + { threshold: 0.1 }, 53 + ); 54 + 55 + if (loadMoreRef.current) { 56 + observer.observe(loadMoreRef.current); 57 + } 58 + 59 + return () => observer.disconnect(); 60 + }, [data, size, setSize, isValidating]); 61 + 62 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 63 + 64 + if (allPosts.length === 0 && !isValidating) { 65 + return ( 66 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 67 + No posts yet. Check back soon! 68 + </div> 69 + ); 70 + } 71 + 72 + return ( 73 + <div className="flex flex-row gap-6 w-full"> 74 + <div className="flex flex-col gap-6 w-full relative"> 75 + {allPosts.map((p) => ( 76 + <PostListing {...p} key={p.documents.uri} /> 77 + ))} 78 + <div 79 + ref={loadMoreRef} 80 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 81 + aria-hidden="true" 82 + /> 83 + {isValidating && ( 84 + <div className="text-center text-tertiary py-4"> 85 + Loading more posts... 86 + </div> 87 + )} 88 + </div> 89 + <DesktopInteractionPreviewDrawer /> 90 + <MobileInteractionPreviewDrawer /> 91 + </div> 92 + ); 93 + };
app/(home-pages)/reader/PreviewDrawer.tsx

This is a binary file and will not be displayed.

+40 -21
app/(home-pages)/reader/ReaderContent.tsx app/(home-pages)/reader/InboxContent.tsx
··· 1 1 "use client"; 2 + import { use } from "react"; 2 3 import { ButtonPrimary } from "components/Buttons"; 3 4 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 4 5 import type { Cursor, Post } from "./getReaderFeed"; ··· 7 8 import { useEffect, useRef } from "react"; 8 9 import Link from "next/link"; 9 10 import { PostListing } from "components/PostListing"; 11 + import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage"; 12 + import { 13 + DesktopInteractionPreviewDrawer, 14 + MobileInteractionPreviewDrawer, 15 + } from "./InteractionDrawers"; 10 16 11 - export const ReaderContent = (props: { 12 - posts: Post[]; 13 - nextCursor: Cursor | null; 17 + export const InboxContent = (props: { 18 + promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; 14 19 }) => { 20 + const { posts, nextCursor } = use(props.promise); 21 + 15 22 const getKey = ( 16 23 pageIndex: number, 17 24 previousPageData: { ··· 33 40 getKey, 34 41 ([_, cursor]) => getReaderFeed(cursor), 35 42 { 36 - fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 43 + fallbackData: [{ posts, nextCursor }], 37 44 revalidateFirstPage: false, 38 45 }, 39 46 ); ··· 63 70 64 71 const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 72 73 + const sortedPosts = allPosts.sort( 74 + (a, b) => 75 + new Date(b.documents.data?.publishedAt || 0).getTime() - 76 + new Date(a.documents.data?.publishedAt || 0).getTime(), 77 + ); 78 + 66 79 if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 67 80 81 + let hasBackgroundImage = useHasBackgroundImage(); 82 + 68 83 return ( 69 - <div className="flex flex-col gap-3 relative"> 70 - {allPosts.map((p) => ( 71 - <PostListing {...p} key={p.documents.uri} /> 72 - ))} 73 - {/* Trigger element for loading more posts */} 74 - <div 75 - ref={loadMoreRef} 76 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 77 - aria-hidden="true" 78 - /> 79 - {isValidating && ( 80 - <div className="text-center text-tertiary py-4"> 81 - Loading more posts... 82 - </div> 83 - )} 84 + <div className="flex flex-row gap-6 w-full "> 85 + <div className="flex flex-col gap-6 w-full relative"> 86 + {sortedPosts.map((p) => ( 87 + <PostListing {...p} key={p.documents.uri} /> 88 + ))} 89 + {/* Trigger element for loading more posts */} 90 + <div 91 + ref={loadMoreRef} 92 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 93 + aria-hidden="true" 94 + /> 95 + {isValidating && ( 96 + <div className="text-center text-tertiary py-4"> 97 + Loading more posts... 98 + </div> 99 + )} 100 + </div> 101 + <DesktopInteractionPreviewDrawer /> 102 + <MobileInteractionPreviewDrawer /> 84 103 </div> 85 104 ); 86 105 }; ··· 90 109 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 91 110 Nothing to read yet… <br /> 92 111 Subscribe to publications and find their posts here! 93 - <Link href={"/discover"}> 112 + <Link href={"/reader/hot"}> 94 113 <ButtonPrimary className="mx-auto place-self-center"> 95 - <DiscoverSmall /> Discover Publications 114 + <DiscoverSmall /> See what posts people are reading! 96 115 </ButtonPrimary> 97 116 </Link> 98 117 </div>
+72
app/(home-pages)/reader/ReaderMentionsContent.tsx
··· 1 + "use client"; 2 + import useSWR from "swr"; 3 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4 + import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; 5 + import { DotLoader } from "components/utils/DotLoader"; 6 + 7 + async function fetchBskyPosts(uris: string[]): Promise<PostView[]> { 8 + const params = new URLSearchParams({ 9 + uris: JSON.stringify(uris), 10 + }); 11 + const response = await fetch(`/api/bsky/hydrate?${params.toString()}`); 12 + if (!response.ok) throw new Error("Failed to fetch Bluesky posts"); 13 + return response.json(); 14 + } 15 + 16 + export function ReaderMentionsContent(props: { 17 + quotesAndMentions: { uri: string; link?: string }[]; 18 + }) { 19 + const uris = props.quotesAndMentions.map((q) => q.uri); 20 + const key = uris.length > 0 21 + ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}` 22 + : null; 23 + 24 + const { data: bskyPosts, isLoading } = useSWR(key, () => 25 + fetchBskyPosts(uris), 26 + ); 27 + 28 + if (props.quotesAndMentions.length === 0) { 29 + return ( 30 + <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 31 + <div className="font-bold">no mentions yet!</div> 32 + </div> 33 + ); 34 + } 35 + 36 + if (isLoading) { 37 + return ( 38 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 39 + <span>loading</span> 40 + <DotLoader /> 41 + </div> 42 + ); 43 + } 44 + 45 + const postViewMap = new Map<string, PostView>(); 46 + bskyPosts?.forEach((pv) => postViewMap.set(pv.uri, pv)); 47 + 48 + return ( 49 + <div className="flex flex-col gap-4 w-full"> 50 + {props.quotesAndMentions.map((q, index) => { 51 + const post = postViewMap.get(q.uri); 52 + if (!post) return null; 53 + return ( 54 + <div key={q.uri}> 55 + <BskyPostContent 56 + post={post} 57 + parent={undefined} 58 + showBlueskyLink={true} 59 + showEmbed={true} 60 + avatarSize="medium" 61 + className="text-sm" 62 + compactEmbed 63 + /> 64 + {index < props.quotesAndMentions.length - 1 && ( 65 + <hr className="border-border-light mt-4" /> 66 + )} 67 + </div> 68 + ); 69 + })} 70 + </div> 71 + ); 72 + }
-105
app/(home-pages)/reader/SubscriptionsContent.tsx
··· 1 - "use client"; 2 - import { PubListing } from "app/(home-pages)/discover/PubListing"; 3 - import { ButtonPrimary } from "components/Buttons"; 4 - import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 5 - import { Json } from "supabase/database.types"; 6 - import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; 7 - import useSWRInfinite from "swr/infinite"; 8 - import { useEffect, useRef } from "react"; 9 - import { Cursor } from "./getReaderFeed"; 10 - import Link from "next/link"; 11 - 12 - export const SubscriptionsContent = (props: { 13 - publications: PublicationSubscription[]; 14 - nextCursor: Cursor | null; 15 - }) => { 16 - const getKey = ( 17 - pageIndex: number, 18 - previousPageData: { 19 - subscriptions: PublicationSubscription[]; 20 - nextCursor: Cursor | null; 21 - } | null, 22 - ) => { 23 - // Reached the end 24 - if (previousPageData && !previousPageData.nextCursor) return null; 25 - 26 - // First page, we don't have previousPageData 27 - if (pageIndex === 0) return ["subscriptions", null] as const; 28 - 29 - // Add the cursor to the key 30 - return ["subscriptions", previousPageData?.nextCursor] as const; 31 - }; 32 - 33 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 - getKey, 35 - ([_, cursor]) => getSubscriptions(null, cursor), 36 - { 37 - fallbackData: [ 38 - { subscriptions: props.publications, nextCursor: props.nextCursor }, 39 - ], 40 - revalidateFirstPage: false, 41 - }, 42 - ); 43 - 44 - const loadMoreRef = useRef<HTMLDivElement>(null); 45 - 46 - // Set up intersection observer to load more when trigger element is visible 47 - useEffect(() => { 48 - const observer = new IntersectionObserver( 49 - (entries) => { 50 - if (entries[0].isIntersecting && !isValidating) { 51 - const hasMore = data && data[data.length - 1]?.nextCursor; 52 - if (hasMore) { 53 - setSize(size + 1); 54 - } 55 - } 56 - }, 57 - { threshold: 0.1 }, 58 - ); 59 - 60 - if (loadMoreRef.current) { 61 - observer.observe(loadMoreRef.current); 62 - } 63 - 64 - return () => observer.disconnect(); 65 - }, [data, size, setSize, isValidating]); 66 - 67 - const allPublications = data 68 - ? data.flatMap((page) => page.subscriptions) 69 - : []; 70 - 71 - if (allPublications.length === 0 && !isValidating) 72 - return <SubscriptionsEmpty />; 73 - 74 - return ( 75 - <div className="relative"> 76 - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 77 - {allPublications?.map((p, index) => <PubListing key={p.uri} {...p} />)} 78 - </div> 79 - {/* Trigger element for loading more subscriptions */} 80 - <div 81 - ref={loadMoreRef} 82 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 83 - aria-hidden="true" 84 - /> 85 - {isValidating && ( 86 - <div className="text-center text-tertiary py-4"> 87 - Loading more subscriptions... 88 - </div> 89 - )} 90 - </div> 91 - ); 92 - }; 93 - 94 - export const SubscriptionsEmpty = () => { 95 - return ( 96 - <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 97 - You haven't subscribed to any publications yet! 98 - <Link href={"/discover"}> 99 - <ButtonPrimary className="mx-auto place-self-center"> 100 - <DiscoverSmall /> Discover Publications 101 - </ButtonPrimary> 102 - </Link> 103 - </div> 104 - ); 105 - };
+88
app/(home-pages)/reader/enrichPost.ts
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 3 + import { getConstellationBacklinks } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; 4 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 5 + import { 6 + normalizeDocumentRecord, 7 + normalizePublicationRecord, 8 + } from "src/utils/normalizeRecords"; 9 + import { idResolver } from "./idResolver"; 10 + import type { Post } from "./getReaderFeed"; 11 + 12 + type RawDocument = { 13 + data: unknown; 14 + uri: string; 15 + sort_date: string; 16 + comments_on_documents: { count: number }[]; 17 + document_mentions_in_bsky: { count: number }[]; 18 + recommends_on_documents: { count: number }[]; 19 + documents_in_publications: { 20 + publications: { 21 + uri: string; 22 + record: unknown; 23 + name: string | null; 24 + [key: string]: unknown; 25 + } | null; 26 + }[]; 27 + }; 28 + 29 + export async function enrichDocumentToPost( 30 + doc: RawDocument, 31 + ): Promise<Post | null> { 32 + const pub = doc.documents_in_publications?.[0]?.publications; 33 + const uri = new AtUri(doc.uri); 34 + const handle = await idResolver.did.resolve(uri.host); 35 + 36 + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 37 + if (!normalizedData) return null; 38 + 39 + const normalizedPubRecord = pub 40 + ? normalizePublicationRecord(pub.record) 41 + : null; 42 + 43 + const mentionsCount = await getAccurateMentionsCount( 44 + normalizedData, 45 + doc.uri, 46 + normalizedPubRecord, 47 + doc.document_mentions_in_bsky?.[0]?.count || 0, 48 + ); 49 + 50 + return { 51 + publication: pub 52 + ? { 53 + href: getPublicationURL(pub), 54 + pubRecord: normalizedPubRecord, 55 + uri: pub.uri || "", 56 + } 57 + : undefined, 58 + author: handle?.alsoKnownAs?.[0] 59 + ? `@${handle.alsoKnownAs[0].slice(5)}` 60 + : null, 61 + documents: { 62 + comments_on_documents: doc.comments_on_documents, 63 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 64 + recommends_on_documents: doc.recommends_on_documents, 65 + mentionsCount, 66 + data: normalizedData, 67 + uri: doc.uri, 68 + sort_date: doc.sort_date, 69 + }, 70 + }; 71 + } 72 + 73 + async function getAccurateMentionsCount( 74 + normalizedData: NonNullable<ReturnType<typeof normalizeDocumentRecord>>, 75 + docUri: string, 76 + normalizedPubRecord: ReturnType<typeof normalizePublicationRecord>, 77 + dbMentionsCount: number, 78 + ): Promise<number> { 79 + const postUrl = getDocumentURL(normalizedData, docUri, normalizedPubRecord); 80 + const absoluteUrl = postUrl.startsWith("/") 81 + ? `https://leaflet.pub${postUrl}` 82 + : postUrl; 83 + const constellationBacklinks = await getConstellationBacklinks(absoluteUrl); 84 + const uniqueBacklinkCount = new Set( 85 + constellationBacklinks.map((b) => b.uri), 86 + ).size; 87 + return dbMentionsCount + uniqueBacklinkCount; 88 + }
+100
app/(home-pages)/reader/getHotFeed.ts
··· 1 + "use server"; 2 + 3 + import { drizzle } from "drizzle-orm/node-postgres"; 4 + import { sql } from "drizzle-orm"; 5 + import { pool } from "supabase/pool"; 6 + import Client from "ioredis"; 7 + import { AtUri } from "@atproto/api"; 8 + import { supabaseServerClient } from "supabase/serverClient"; 9 + import { enrichDocumentToPost } from "./enrichPost"; 10 + import type { Post } from "./getReaderFeed"; 11 + 12 + let redisClient: Client | null = null; 13 + if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { 14 + redisClient = new Client(process.env.REDIS_URL); 15 + } 16 + 17 + const CACHE_KEY = "hot_feed_v1"; 18 + const CACHE_TTL = 300; // 5 minutes 19 + 20 + export async function getHotFeed(): Promise<{ posts: Post[] }> { 21 + // Check Redis cache 22 + if (redisClient) { 23 + const cached = await redisClient.get(CACHE_KEY); 24 + if (cached) { 25 + return JSON.parse(cached) as { posts: Post[] }; 26 + } 27 + } 28 + 29 + // Run ranked SQL query to get top 50 URIs 30 + const client = await pool.connect(); 31 + const db = drizzle(client); 32 + 33 + let uris: string[]; 34 + try { 35 + const ranked = await db.execute(sql` 36 + SELECT uri 37 + FROM documents 38 + WHERE indexed = true 39 + AND sort_date > now() - interval '7 days' 40 + ORDER BY 41 + (bsky_like_count + recommend_count * 5)::numeric 42 + / power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC 43 + LIMIT 50 44 + `); 45 + uris = ranked.rows.map((row: any) => row.uri as string); 46 + } finally { 47 + client.release(); 48 + } 49 + 50 + if (uris.length === 0) { 51 + return { posts: [] }; 52 + } 53 + 54 + // Batch-fetch documents with publication joins and interaction counts 55 + const { data: documents } = await supabaseServerClient 56 + .from("documents") 57 + .select( 58 + `*, 59 + comments_on_documents(count), 60 + document_mentions_in_bsky(count), 61 + recommends_on_documents(count), 62 + documents_in_publications(publications(*))`, 63 + ) 64 + .in("uri", uris); 65 + 66 + // Build lookup map for enrichment 67 + const docMap = new Map((documents || []).map((d) => [d.uri, d])); 68 + 69 + // Process in ranked order, deduplicating by identity key (DID/rkey) 70 + const seen = new Set<string>(); 71 + const orderedDocs: NonNullable<typeof documents>[number][] = []; 72 + for (const uri of uris) { 73 + try { 74 + const parsed = new AtUri(uri); 75 + const identityKey = `${parsed.host}/${parsed.rkey}`; 76 + if (seen.has(identityKey)) continue; 77 + seen.add(identityKey); 78 + } catch { 79 + // invalid URI, skip dedup check 80 + } 81 + const doc = docMap.get(uri); 82 + if (doc) orderedDocs.push(doc); 83 + } 84 + 85 + // Enrich into Post[] 86 + const posts = ( 87 + await Promise.all( 88 + orderedDocs.map((doc) => enrichDocumentToPost(doc as any)), 89 + ) 90 + ).filter((post): post is Post => post !== null); 91 + 92 + const response = { posts }; 93 + 94 + // Cache in Redis 95 + if (redisClient) { 96 + await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response)); 97 + } 98 + 99 + return response; 100 + }
+54
app/(home-pages)/reader/getNewFeed.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 5 + import { enrichDocumentToPost } from "./enrichPost"; 6 + import type { Cursor, Post } from "./getReaderFeed"; 7 + 8 + export async function getNewFeed( 9 + cursor?: Cursor | null, 10 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 11 + let query = supabaseServerClient 12 + .from("documents") 13 + .select( 14 + `*, 15 + comments_on_documents(count), 16 + document_mentions_in_bsky(count), 17 + recommends_on_documents(count), 18 + documents_in_publications!inner(publications!inner(*))`, 19 + ) 20 + .or( 21 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 22 + { referencedTable: "documents_in_publications.publications" }, 23 + ) 24 + .order("sort_date", { ascending: false }) 25 + .order("uri", { ascending: false }) 26 + .limit(25); 27 + 28 + if (cursor) { 29 + query = query.or( 30 + `sort_date.lt.${cursor.timestamp},and(sort_date.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 31 + ); 32 + } 33 + 34 + let { data: rawFeed, error } = await query; 35 + 36 + const feed = deduplicateByUriOrdered(rawFeed || []); 37 + 38 + let posts = ( 39 + await Promise.all(feed.map((post) => enrichDocumentToPost(post as any))) 40 + ).filter((post): post is Post => post !== null); 41 + 42 + const nextCursor = 43 + posts.length > 0 44 + ? { 45 + timestamp: posts[posts.length - 1].documents.sort_date, 46 + uri: posts[posts.length - 1].documents.uri, 47 + } 48 + : null; 49 + 50 + return { 51 + posts, 52 + nextCursor, 53 + }; 54 + }
+9 -43
app/(home-pages)/reader/getReaderFeed.ts
··· 1 1 "use server"; 2 2 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 4 import { supabaseServerClient } from "supabase/serverClient"; 6 - import { IdResolver } from "@atproto/identity"; 7 - import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 - import Client from "ioredis"; 9 - import { AtUri } from "@atproto/api"; 10 - import { idResolver } from "./idResolver"; 11 - import { 12 - normalizeDocumentRecord, 13 - normalizePublicationRecord, 14 - type NormalizedDocument, 15 - type NormalizedPublication, 5 + import type { 6 + NormalizedDocument, 7 + NormalizedPublication, 16 8 } from "src/utils/normalizeRecords"; 17 9 import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 10 + import { enrichDocumentToPost } from "./enrichPost"; 18 11 19 12 export type Cursor = { 20 13 timestamp: string; ··· 32 25 `*, 33 26 comments_on_documents(count), 34 27 document_mentions_in_bsky(count), 28 + recommends_on_documents(count), 35 29 documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 36 30 ) 37 31 .eq( ··· 52 46 const feed = deduplicateByUriOrdered(rawFeed || []); 53 47 54 48 let posts = ( 55 - await Promise.all( 56 - feed.map(async (post) => { 57 - let pub = post.documents_in_publications[0].publications!; 58 - let uri = new AtUri(post.uri); 59 - let handle = await idResolver.did.resolve(uri.host); 60 - 61 - // Normalize records - filter out unrecognized formats 62 - const normalizedData = normalizeDocumentRecord(post.data, post.uri); 63 - if (!normalizedData) return null; 64 - 65 - const normalizedPubRecord = normalizePublicationRecord(pub?.record); 66 - 67 - let p: Post = { 68 - publication: { 69 - href: getPublicationURL(pub), 70 - pubRecord: normalizedPubRecord, 71 - uri: pub?.uri || "", 72 - }, 73 - author: handle?.alsoKnownAs?.[0] 74 - ? `@${handle.alsoKnownAs[0].slice(5)}` 75 - : null, 76 - documents: { 77 - comments_on_documents: post.comments_on_documents, 78 - document_mentions_in_bsky: post.document_mentions_in_bsky, 79 - data: normalizedData, 80 - uri: post.uri, 81 - sort_date: post.sort_date, 82 - }, 83 - }; 84 - return p; 85 - }) || [], 86 - ) 49 + await Promise.all(feed.map((post) => enrichDocumentToPost(post as any))) 87 50 ).filter((post): post is Post => post !== null); 51 + 88 52 const nextCursor = 89 53 posts.length > 0 90 54 ? { ··· 112 76 sort_date: string; 113 77 comments_on_documents: { count: number }[] | undefined; 114 78 document_mentions_in_bsky: { count: number }[] | undefined; 79 + recommends_on_documents: { count: number }[] | undefined; 80 + mentionsCount?: number; 115 81 }; 116 82 };
+6 -4
app/(home-pages)/reader/getSubscriptions.ts
··· 29 29 30 30 let query = supabaseServerClient 31 31 .from("publication_subscriptions") 32 - .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 32 + .select( 33 + `*, publications(*, publication_subscriptions(*), documents_in_publications(*, documents(*)))`, 34 + ) 33 35 .order(`created_at`, { ascending: false }) 34 36 .order(`uri`, { ascending: false }) 35 37 .order("documents(sort_date)", { ··· 51 53 await Promise.all( 52 54 pubs?.map(async (pub) => { 53 55 const normalizedRecord = normalizePublicationRecord( 54 - pub.publications?.record 56 + pub.publications?.record, 55 57 ); 56 58 if (!normalizedRecord) return null; 57 59 let id = await idResolver.did.resolve(pub.publications?.identity_did!); ··· 62 64 ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 63 65 : undefined, 64 66 } as PublicationSubscription; 65 - }) || [] 67 + }) || [], 66 68 ) 67 69 ).filter((sub): sub is PublicationSubscription => sub !== null); 68 - 69 70 const nextCursor = 70 71 pubs && pubs.length > 0 71 72 ? { ··· 83 84 export type PublicationSubscription = { 84 85 authorProfile?: { handle: string }; 85 86 record: NormalizedPublication; 87 + publication_subscriptions: { identity: string }[]; 86 88 uri: string; 87 89 documents_in_publications: { 88 90 documents: { data?: Json; sort_date: string } | null;
+7
app/(home-pages)/reader/hot/page.tsx
··· 1 + import { getHotFeed } from "../getHotFeed"; 2 + import { GlobalContent } from "../GlobalContent"; 3 + 4 + export default async function HotPage() { 5 + const feedPromise = getHotFeed(); 6 + return <GlobalContent promise={feedPromise} />; 7 + }
+79
app/(home-pages)/reader/layout.tsx
··· 1 + "use client"; 2 + 3 + import { usePathname } from "next/navigation"; 4 + import Link from "next/link"; 5 + import { Header } from "components/PageHeader"; 6 + import { Footer } from "components/ActionBar/Footer"; 7 + import { DesktopNavigation } from "components/ActionBar/DesktopNavigation"; 8 + import { MobileNavigation } from "components/ActionBar/MobileNavigation"; 9 + import { MediaContents } from "components/Media"; 10 + import { DashboardIdContext } from "components/PageLayouts/DashboardLayout"; 11 + import { useIdentityData } from "components/IdentityProvider"; 12 + 13 + const allTabs = [ 14 + { name: "Subs", href: "/reader", requiresAuth: true }, 15 + { name: "What's Hot", href: "/reader/hot", requiresAuth: false }, 16 + { name: "New", href: "/reader/new", requiresAuth: false }, 17 + ]; 18 + 19 + export default function ReaderLayout({ 20 + children, 21 + }: { 22 + children: React.ReactNode; 23 + }) { 24 + const pathname = usePathname(); 25 + const { identity } = useIdentityData(); 26 + const isLoggedIn = !!identity?.atp_did; 27 + const tabs = allTabs.filter((tab) => !tab.requiresAuth || isLoggedIn); 28 + 29 + const isActive = (href: string) => { 30 + if (href === "/reader") 31 + return pathname === "/reader" || pathname === "/"; 32 + if ( 33 + href === "/reader/hot" && 34 + !isLoggedIn && 35 + (pathname === "/reader" || pathname === "/") 36 + ) 37 + return true; 38 + return pathname.startsWith(href); 39 + }; 40 + 41 + return ( 42 + <DashboardIdContext.Provider value="reader"> 43 + <div className="dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6"> 44 + <MediaContents mobile={false}> 45 + <div className="flex flex-col gap-3 my-6"> 46 + <DesktopNavigation currentPage="reader" /> 47 + </div> 48 + </MediaContents> 49 + <div 50 + className="w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4" 51 + id="home-content" 52 + > 53 + <Header> 54 + <div className="pubDashTabs flex flex-row gap-1"> 55 + {tabs.map((tab) => ( 56 + <Link key={tab.name} href={tab.href}> 57 + <div 58 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${ 59 + isActive(tab.href) 60 + ? "text-accent-2 bg-accent-1 font-bold -mb-px" 61 + : "text-tertiary" 62 + }`} 63 + > 64 + {tab.name} 65 + </div> 66 + </Link> 67 + ))} 68 + </div> 69 + <div className="sm:block grow" /> 70 + </Header> 71 + {children} 72 + </div> 73 + <Footer> 74 + <MobileNavigation currentPage="reader" /> 75 + </Footer> 76 + </div> 77 + </DashboardIdContext.Provider> 78 + ); 79 + }
+4
app/(home-pages)/reader/loading.tsx
··· 1 + import { FeedSkeleton } from "./FeedSkeleton"; 2 + export default function Loading() { 3 + return <FeedSkeleton />; 4 + }
+7
app/(home-pages)/reader/new/page.tsx
··· 1 + import { getNewFeed } from "../getNewFeed"; 2 + import { NewContent } from "../NewContent"; 3 + 4 + export default async function NewPage() { 5 + const feedPromise = getNewFeed(); 6 + return <NewContent promise={feedPromise} />; 7 + }
+12 -33
app/(home-pages)/reader/page.tsx
··· 1 1 import { getIdentityData } from "actions/getIdentityData"; 2 + import { getReaderFeed } from "./getReaderFeed"; 3 + import { getHotFeed } from "./getHotFeed"; 4 + import { InboxContent } from "./InboxContent"; 5 + import { GlobalContent } from "./GlobalContent"; 2 6 3 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 4 - import { ReaderContent } from "./ReaderContent"; 5 - import { SubscriptionsContent } from "./SubscriptionsContent"; 6 - import { getReaderFeed } from "./getReaderFeed"; 7 - import { getSubscriptions } from "./getSubscriptions"; 7 + export default async function Reader() { 8 + let identityData = await getIdentityData(); 9 + if (!identityData?.atp_did) { 10 + const feedPromise = getHotFeed(); 11 + return <GlobalContent promise={feedPromise} />; 12 + } 8 13 9 - export default async function Reader(props: {}) { 10 - let posts = await getReaderFeed(); 11 - let publications = await getSubscriptions(); 12 - return ( 13 - <DashboardLayout 14 - id="reader" 15 - currentPage="reader" 16 - defaultTab="Read" 17 - actions={null} 18 - tabs={{ 19 - Read: { 20 - controls: null, 21 - content: ( 22 - <ReaderContent nextCursor={posts.nextCursor} posts={posts.posts} /> 23 - ), 24 - }, 25 - Subscriptions: { 26 - controls: null, 27 - content: ( 28 - <SubscriptionsContent 29 - publications={publications.subscriptions} 30 - nextCursor={publications.nextCursor} 31 - /> 32 - ), 33 - }, 34 - }} 35 - /> 36 - ); 14 + const feedPromise = getReaderFeed(); 15 + return <InboxContent promise={feedPromise} />; 37 16 }
+2
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 21 21 `*, 22 22 comments_on_documents(count), 23 23 document_mentions_in_bsky(count), 24 + recommends_on_documents(count), 24 25 documents_in_publications(publications(*))`, 25 26 ) 26 27 .contains("data->tags", `["${tag}"]`) ··· 67 68 documents: { 68 69 comments_on_documents: doc.comments_on_documents, 69 70 document_mentions_in_bsky: doc.document_mentions_in_bsky, 71 + recommends_on_documents: doc.recommends_on_documents, 70 72 data: normalizedData, 71 73 uri: doc.uri, 72 74 sort_date: doc.sort_date,
+14 -8
app/[leaflet_id]/Footer.tsx
··· 1 1 "use client"; 2 2 import { useUIState } from "src/useUIState"; 3 - import { Footer as ActionFooter } from "components/ActionBar/Footer"; 3 + import { Footer } from "components/ActionBar/Footer"; 4 4 import { Media } from "components/Media"; 5 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 6 import { Toolbar } from "components/Toolbar"; ··· 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 15 import { useEntity } from "src/replicache"; 16 16 import { block } from "sharp"; 17 + import { PostSettings } from "components/PostSettings"; 17 18 18 19 export function hasBlockToolbar(blockType: string | null | undefined) { 19 20 return ( ··· 35 36 .value; 36 37 37 38 return ( 38 - <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 39 + <Media 40 + mobile 41 + className="mobileLeafletFooter w-full z-10 touch-none -mt-[54px] " 42 + > 39 43 {focusedBlock && 40 44 focusedBlock.entityType == "block" && 41 45 hasBlockToolbar(blockType) && ··· 53 57 /> 54 58 </div> 55 59 ) : entity_set.permissions.write ? ( 56 - <ActionFooter> 60 + <Footer> 57 61 {pub?.publications && 58 62 identity?.atp_did && 59 63 pub.publications.identity_did === identity.atp_did ? ( ··· 61 65 ) : ( 62 66 <HomeButton /> 63 67 )} 64 - 65 - <PublishButton entityID={props.entityID} /> 66 - <ShareOptions /> 67 - <ThemePopover entityID={props.entityID} /> 68 - </ActionFooter> 68 + <div className="mobileLeafletActions flex gap-2 shrink-0"> 69 + <PublishButton entityID={props.entityID} /> 70 + <ShareOptions /> 71 + <PostSettings /> 72 + <ThemePopover entityID={props.entityID} /> 73 + </div> 74 + </Footer> 69 75 ) : ( 70 76 <div className="pb-2 px-2 z-10 flex justify-end"> 71 77 <Watermark mobile />
+2
app/[leaflet_id]/Sidebar.tsx
··· 8 8 import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 9 9 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 10 10 import { PublishButton } from "./actions/PublishButton"; 11 + import { PostSettings } from "components/PostSettings"; 11 12 import { Watermark } from "components/Watermark"; 12 13 import { BackToPubButton } from "./actions/BackToPubButton"; 13 14 import { useIdentityData } from "components/IdentityProvider"; ··· 30 31 <Sidebar> 31 32 <PublishButton entityID={rootEntity} /> 32 33 <ShareOptions /> 34 + <PostSettings /> 33 35 <ThemePopover entityID={rootEntity} /> 34 36 <HelpButton /> 35 37 <hr className="text-border" />
+18 -5
app/[leaflet_id]/actions/PublishButton.tsx
··· 96 96 tx.get<string | null>("publication_cover_image"), 97 97 ); 98 98 99 + // Get post preferences from Replicache state 100 + let postPreferences = useSubscribe(rep, (tx) => 101 + tx.get<{ 102 + showComments?: boolean; 103 + showMentions?: boolean; 104 + showRecommends?: boolean; 105 + } | null>("post_preferences"), 106 + ); 107 + 99 108 // Get local published at from Replicache (session-only state, not persisted to DB) 100 109 let publishedAt = useLocalPublishedAt((s) => 101 110 pub?.doc ? s[pub?.doc] : undefined, ··· 118 127 tags: currentTags, 119 128 cover_image: coverImage, 120 129 publishedAt: publishedAt?.toISOString(), 130 + postPreferences, 121 131 }); 122 132 setIsLoading(false); 123 133 mutate(); ··· 335 345 <div className="text-sm text-tertiary">Publish to…</div> 336 346 {props.publications.length === 0 || props.publications === undefined ? ( 337 347 <div className="flex flex-col gap-1"> 338 - <div className="flex gap-2 menuItem"> 339 - <LooseLeafSmall className="shrink-0" /> 348 + <PubOption 349 + selected={props.selectedPub === "looseleaf"} 350 + onSelect={() => props.setSelectedPub("looseleaf")} 351 + > 352 + <LooseLeafSmall /> 340 353 <div className="flex flex-col leading-snug"> 341 354 <div className="text-secondary font-bold"> 342 355 Publish as Looseleaf 343 356 </div> 344 357 <div className="text-tertiary text-sm font-normal"> 345 - Publish this as a one off doc to AT Proto 358 + Publish as a one-off doc to AT Proto 346 359 </div> 347 360 </div> 348 - </div> 361 + </PubOption> 349 362 <div className="flex gap-2 px-2 py-1 "> 350 363 <PublishSmall className="shrink-0 text-border" /> 351 364 <div className="flex flex-col leading-snug"> ··· 353 366 Publish to Publication 354 367 </div> 355 368 <div className="text-border text-sm font-normal"> 356 - Publish your writing to a blog on AT Proto 369 + Publish to a blog on AT Proto 357 370 </div> 358 371 <hr className="my-2 drashed border-border-light border-dashed" /> 359 372 <div className="text-tertiary text-sm font-normal ">
+9 -7
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 14 14 useLeafletPublicationData, 15 15 } from "components/PageSWRDataProvider"; 16 16 import { ShareSmall } from "components/Icons/ShareSmall"; 17 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 17 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 18 18 import { AtUri } from "@atproto/syntax"; 19 19 import { useIsMobile } from "src/hooks/isMobile"; 20 20 ··· 89 89 let { permission_token } = useReplicache(); 90 90 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 91 91 92 - let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 93 - let postLink = !docURI 94 - ? null 95 - : pub?.publications 96 - ? `${getPublicationURL(pub.publications)}/${docURI.rkey}` 97 - : `p/${docURI.host}/${docURI.rkey}`; 92 + let postLink = 93 + pub?.documents && normalizedDocument 94 + ? getDocumentURL( 95 + normalizedDocument, 96 + pub.documents.uri, 97 + pub?.publications || null, 98 + ) 99 + : null; 98 100 let publishLink = useReadOnlyShareLink(); 99 101 let [collabLink, setCollabLink] = useState<null | string>(null); 100 102 useEffect(() => {
+10
app/[leaflet_id]/publish/PublishPost.tsx
··· 91 91 tx.get<string | null>("publication_cover_image"), 92 92 ); 93 93 94 + // Get post preferences from Replicache state 95 + let postPreferences = useSubscribe(rep, (tx) => 96 + tx.get<{ 97 + showComments?: boolean; 98 + showMentions?: boolean; 99 + showRecommends?: boolean; 100 + } | null>("post_preferences"), 101 + ); 102 + 94 103 // Use Replicache tags only when we have a draft 95 104 const currentTags = props.hasDraft 96 105 ? Array.isArray(replicacheTags) ··· 124 133 cover_image: replicacheCoverImage, 125 134 entitiesToDelete: props.entitiesToDelete, 126 135 publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(), 136 + postPreferences, 127 137 }); 128 138 129 139 if (!result.success) {
+6
app/api/inngest/client.ts
··· 51 51 documentUris?: string[]; 52 52 }; 53 53 }; 54 + "appview/sync-document-metadata": { 55 + data: { 56 + document_uri: string; 57 + bsky_post_uri?: string; 58 + }; 59 + }; 54 60 "user/write-records-to-pds": { 55 61 data: { 56 62 did: string;
+12 -3
app/api/inngest/functions/index_post_mention.ts
··· 3 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 4 import { Json } from "supabase/database.types"; 5 5 import { ids } from "lexicons/api/lexicons"; 6 - import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 6 + import { 7 + Notification, 8 + pingIdentityToUpdateNotification, 9 + } from "src/notifications"; 7 10 import { v7 } from "uuid"; 8 11 import { idResolver } from "app/(home-pages)/reader/idResolver"; 9 12 import { documentUriFilter } from "src/utils/uriHelpers"; ··· 60 63 let { data: pub, error } = await supabaseServerClient 61 64 .from("publications") 62 65 .select("*") 63 - .or(`record->>base_path.eq.${url.host},record->>url.eq.https://${url.host}`) 66 + .or( 67 + `record->>base_path.eq.${url.host},record->>url.eq.https://${url.host}`, 68 + ) 69 + .order("uri", { ascending: false }) 70 + .limit(1) 64 71 .single(); 65 72 66 73 if (!pub) { ··· 80 87 const docData = docDataArr?.[0]; 81 88 82 89 if (!docData) { 83 - return { message: `No document found for publication ${url.host}/${path[0]}` }; 90 + return { 91 + message: `No document found for publication ${url.host}/${path[0]}`, 92 + }; 84 93 } 85 94 86 95 documentUri = docData.uri;
+79
app/api/inngest/functions/sync_document_metadata.ts
··· 1 + import { inngest } from "../client"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { AtpAgent, AtUri } from "@atproto/api"; 4 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 + 6 + // 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total) 7 + const SLEEP_INTERVALS = [ 8 + "1m", "2m", "4m", "8m", "16m", "32m", "1h", "2h", "4h", "8h", "8h", "8h", 9 + ]; 10 + 11 + export const sync_document_metadata = inngest.createFunction( 12 + { 13 + id: "sync_document_metadata_v2", 14 + debounce: { 15 + key: "event.data.document_uri", 16 + period: "60s", 17 + timeout: "3m", 18 + }, 19 + concurrency: [{ key: "event.data.document_uri", limit: 1 }], 20 + }, 21 + { event: "appview/sync-document-metadata" }, 22 + async ({ event, step }) => { 23 + const { document_uri, bsky_post_uri } = event.data; 24 + 25 + const did = new AtUri(document_uri).host; 26 + 27 + const handleResult = await step.run("resolve-handle", async () => { 28 + const doc = await idResolver.did.resolve(did); 29 + const handle = doc?.alsoKnownAs 30 + ?.find((a) => a.startsWith("at://")) 31 + ?.replace("at://", ""); 32 + if (!doc) return null; 33 + const isBridgy = !!doc?.service?.find( 34 + (s) => 35 + typeof s.serviceEndpoint === "string" && 36 + s.serviceEndpoint.includes("atproto.brid.gy"), 37 + ); 38 + return { handle: handle ?? null, isBridgy, doc }; 39 + }); 40 + if (!handleResult) return { error: "No Handle" }; 41 + 42 + await step.run("set-indexed", async () => { 43 + return await supabaseServerClient 44 + .from("documents") 45 + .update({ indexed: !handleResult.isBridgy }) 46 + .eq("uri", document_uri) 47 + .select(); 48 + }); 49 + 50 + if (!bsky_post_uri || handleResult.isBridgy) { 51 + return { handle: handleResult.handle }; 52 + } 53 + 54 + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 55 + 56 + const fetchAndUpdate = async () => { 57 + const res = await agent.app.bsky.feed.getPosts({ 58 + uris: [bsky_post_uri], 59 + }); 60 + const post = res.data.posts[0]; 61 + if (!post) return 0; 62 + const likeCount = post.likeCount ?? 0; 63 + await supabaseServerClient 64 + .from("documents") 65 + .update({ bsky_like_count: likeCount }) 66 + .eq("uri", document_uri); 67 + return likeCount; 68 + }; 69 + 70 + let likeCount = await step.run("sync-0", fetchAndUpdate); 71 + 72 + for (let i = 0; i < SLEEP_INTERVALS.length; i++) { 73 + await step.sleep(`wait-${i + 1}`, SLEEP_INTERVALS[i]); 74 + likeCount = await step.run(`sync-${i + 1}`, fetchAndUpdate); 75 + } 76 + 77 + return { likeCount, handle: handleResult.handle }; 78 + }, 79 + );
+2
app/api/inngest/route.tsx
··· 17 17 import { stripe_handle_subscription_updated } from "./functions/stripe_handle_subscription_updated"; 18 18 import { stripe_handle_subscription_deleted } from "./functions/stripe_handle_subscription_deleted"; 19 19 import { stripe_handle_invoice_payment_failed } from "./functions/stripe_handle_invoice_payment_failed"; 20 + import { sync_document_metadata } from "./functions/sync_document_metadata"; 20 21 21 22 export const { GET, POST, PUT } = serve({ 22 23 client: inngest, ··· 36 37 stripe_handle_subscription_updated, 37 38 stripe_handle_subscription_deleted, 38 39 stripe_handle_invoice_payment_failed, 40 + sync_document_metadata, 39 41 ], 40 42 });
+2 -1
app/api/oauth/[route]/oauth-metadata.ts
··· 7 7 ? "http://localhost:3000" 8 8 : "https://leaflet.pub"; 9 9 10 - const scope = "atproto transition:generic transition:email"; 10 + const scope = 11 + "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview blob:*/*"; 11 12 const localconfig: OAuthClientMetadataInput = { 12 13 client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent(scope)}`, 13 14 client_name: `Leaflet`,
+9 -6
app/api/oauth/[route]/route.ts
··· 42 42 const ac = new AbortController(); 43 43 44 44 const url = await client.authorize(handle || "https://bsky.social", { 45 - scope: "atproto transition:generic transition:email", 45 + scope: 46 + "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview blob:*/*", 46 47 signal: ac.signal, 47 48 state: JSON.stringify(state), 48 49 }); ··· 89 90 // Trigger migration if identity needs it 90 91 const metadata = identity?.metadata as Record<string, unknown> | null; 91 92 if (metadata?.needsStandardSiteMigration) { 92 - await inngest.send({ 93 - name: "user/migrate-to-standard", 94 - data: { did: session.did }, 95 - }); 93 + if (process.env.NODE_ENV === "production") 94 + await inngest.send({ 95 + name: "user/migrate-to-standard", 96 + data: { did: session.did }, 97 + }); 96 98 } 97 99 98 100 let { data: token } = await supabaseServerClient ··· 104 106 }) 105 107 .select() 106 108 .single(); 107 - 109 + console.log({ token }); 108 110 if (token) await setAuthToken(token.id); 109 111 110 112 // Process successful authentication here ··· 113 115 console.log("User authenticated as:", session.did); 114 116 return handleAction(s.action, redirectPath); 115 117 } catch (e) { 118 + console.log(e); 116 119 redirect(redirectPath); 117 120 } 118 121 }
+90
app/api/rpc/[command]/get_document_interactions.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { getConstellationBacklinks } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; 5 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 6 + import { 7 + normalizeDocumentRecord, 8 + normalizePublicationRecord, 9 + } from "src/utils/normalizeRecords"; 10 + 11 + export const get_document_interactions = makeRoute({ 12 + route: "get_document_interactions", 13 + input: z.object({ 14 + document_uri: z.string(), 15 + }), 16 + handler: async ( 17 + { document_uri }, 18 + { supabase }: Pick<Env, "supabase">, 19 + ) => { 20 + let { data: document } = await supabase 21 + .from("documents") 22 + .select( 23 + ` 24 + data, 25 + uri, 26 + comments_on_documents(*, bsky_profiles(*)), 27 + document_mentions_in_bsky(*), 28 + documents_in_publications(publications(*)) 29 + `, 30 + ) 31 + .eq("uri", document_uri) 32 + .limit(1) 33 + .single(); 34 + 35 + if (!document) { 36 + return { comments: [], quotesAndMentions: [], totalMentionsCount: 0 }; 37 + } 38 + 39 + const normalizedData = normalizeDocumentRecord( 40 + document.data, 41 + document.uri, 42 + ); 43 + 44 + const pub = document.documents_in_publications?.[0]?.publications; 45 + const normalizedPubRecord = pub 46 + ? normalizePublicationRecord(pub.record) 47 + : null; 48 + 49 + // Compute document URL for constellation lookup 50 + let absoluteUrl = ""; 51 + if (normalizedData) { 52 + const postUrl = getDocumentURL( 53 + normalizedData, 54 + document.uri, 55 + normalizedPubRecord, 56 + ); 57 + absoluteUrl = postUrl.startsWith("/") 58 + ? `https://leaflet.pub${postUrl}` 59 + : postUrl; 60 + } 61 + 62 + // Fetch constellation backlinks 63 + const constellationBacklinks = absoluteUrl 64 + ? await getConstellationBacklinks(absoluteUrl) 65 + : []; 66 + 67 + // Deduplicate constellation backlinks internally 68 + const uniqueBacklinks = Array.from( 69 + new Map(constellationBacklinks.map((b) => [b.uri, b])).values(), 70 + ); 71 + 72 + // Combine DB mentions and constellation backlinks, deduplicating by URI 73 + const dbMentionUris = new Set( 74 + document.document_mentions_in_bsky.map((m) => m.uri), 75 + ); 76 + const quotesAndMentions: { uri: string; link?: string }[] = [ 77 + ...document.document_mentions_in_bsky.map((m) => ({ 78 + uri: m.uri, 79 + link: m.link, 80 + })), 81 + ...uniqueBacklinks.filter((b) => !dbMentionUris.has(b.uri)), 82 + ]; 83 + 84 + return { 85 + comments: document.comments_on_documents, 86 + quotesAndMentions, 87 + totalMentionsCount: quotesAndMentions.length, 88 + }; 89 + }, 90 + });
+17
app/api/rpc/[command]/get_hot_feed.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { getHotFeed } from "app/(home-pages)/reader/getHotFeed"; 5 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + 7 + export type GetHotFeedReturnType = Awaited< 8 + ReturnType<(typeof get_hot_feed)["handler"]> 9 + >; 10 + 11 + export const get_hot_feed = makeRoute({ 12 + route: "get_hot_feed", 13 + input: z.object({}), 14 + handler: async ({}, {}: Pick<Env, "supabase">) => { 15 + return await getHotFeed(); 16 + }, 17 + });
+5 -1
app/api/rpc/[command]/get_publication_data.ts
··· 40 40 documents_in_publications(documents( 41 41 *, 42 42 comments_on_documents(count), 43 - document_mentions_in_bsky(count) 43 + document_mentions_in_bsky(count), 44 + recommends_on_documents(count) 44 45 )), 45 46 publication_subscriptions(*, identities(bsky_profiles(*))), 46 47 publication_domains(*), ··· 85 86 indexed_at: dip.documents.indexed_at, 86 87 sort_date: dip.documents.sort_date, 87 88 data: dip.documents.data, 89 + bsky_like_count: dip.documents.bsky_like_count, 88 90 commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 89 91 mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, 92 + recommendsCount: 93 + dip.documents.recommends_on_documents?.[0]?.count || 0, 90 94 }; 91 95 }) 92 96 .filter((d): d is NonNullable<typeof d> => d !== null);
+40
app/api/rpc/[command]/get_user_recommendations.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + 6 + export type GetUserRecommendationsReturnType = Awaited< 7 + ReturnType<(typeof get_user_recommendations)["handler"]> 8 + >; 9 + 10 + export const get_user_recommendations = makeRoute({ 11 + route: "get_user_recommendations", 12 + input: z.object({ 13 + documentUris: z.array(z.string()), 14 + }), 15 + handler: async ({ documentUris }, { supabase }: Pick<Env, "supabase">) => { 16 + const identity = await getIdentityData(); 17 + const currentUserDid = identity?.atp_did; 18 + 19 + if (!currentUserDid || documentUris.length === 0) { 20 + return { 21 + result: {} as Record<string, boolean>, 22 + }; 23 + } 24 + 25 + const { data: recommendations } = await supabase 26 + .from("recommends_on_documents") 27 + .select("document") 28 + .eq("recommender_did", currentUserDid) 29 + .in("document", documentUris); 30 + 31 + const recommendedSet = new Set(recommendations?.map((r) => r.document)); 32 + 33 + const result: Record<string, boolean> = {}; 34 + for (const uri of documentUris) { 35 + result[uri] = recommendedSet.has(uri); 36 + } 37 + 38 + return { result }; 39 + }, 40 + });
+7
app/api/rpc/[command]/pull.ts
··· 9 9 import type { Attribute } from "src/replicache/attributes"; 10 10 import { makeRoute } from "../lib"; 11 11 import type { Env } from "./route"; 12 + import type { Json } from "supabase/database.types"; 12 13 13 14 // First define the sub-types for V0 and V1 requests 14 15 const pullRequestV0 = z.object({ ··· 75 76 title: string; 76 77 tags: string[]; 77 78 cover_image: string | null; 79 + preferences: Json | null; 78 80 }[]; 79 81 let pub_patch = publication_data?.[0] 80 82 ? [ ··· 97 99 op: "put", 98 100 key: "publication_cover_image", 99 101 value: publication_data[0].cover_image || null, 102 + }, 103 + { 104 + op: "put", 105 + key: "post_preferences", 106 + value: publication_data[0].preferences || null, 100 107 }, 101 108 ] 102 109 : [];
+6
app/api/rpc/[command]/route.ts
··· 14 14 import { search_publication_names } from "./search_publication_names"; 15 15 import { search_publication_documents } from "./search_publication_documents"; 16 16 import { get_profile_data } from "./get_profile_data"; 17 + import { get_user_recommendations } from "./get_user_recommendations"; 18 + import { get_hot_feed } from "./get_hot_feed"; 19 + import { get_document_interactions } from "./get_document_interactions"; 17 20 18 21 let supabase = createClient<Database>( 19 22 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 41 44 search_publication_names, 42 45 search_publication_documents, 43 46 get_profile_data, 47 + get_user_recommendations, 48 + get_hot_feed, 49 + get_document_interactions, 44 50 ]; 45 51 export async function POST( 46 52 req: Request,
+7 -5
app/api/rpc/[command]/search_publication_documents.ts
··· 2 2 import { z } from "zod"; 3 3 import { makeRoute } from "../lib"; 4 4 import type { Env } from "./route"; 5 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 6 7 7 8 export type SearchPublicationDocumentsReturnType = Awaited< 8 9 ReturnType<(typeof search_publication_documents)["handler"]> ··· 37 38 } 38 39 39 40 const result = documents.map((d) => { 40 - const docUri = new AtUri(d.documents.uri); 41 - const pubUrl = getPublicationURL(d.publications); 41 + const normalizedDoc = normalizeDocumentRecord(d.documents.data, d.documents.uri); 42 42 43 43 return { 44 44 uri: d.documents.uri, 45 - title: (d.documents.data as { title?: string })?.title || "Untitled", 46 - url: `${pubUrl}/${docUri.rkey}`, 45 + title: normalizedDoc?.title || (d.documents.data as { title?: string })?.title || "Untitled", 46 + url: normalizedDoc 47 + ? getDocumentURL(normalizedDoc, d.documents.uri, d.publications) 48 + : `${d.documents.uri}`, 47 49 }; 48 50 }); 49 51
+16
app/api/update-nav-state/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + 3 + export async function POST(request: NextRequest) { 4 + const { state } = (await request.json()) as { state: string }; 5 + if (state !== "home" && state !== "reader") { 6 + return NextResponse.json({ error: "Invalid state" }, { status: 400 }); 7 + } 8 + 9 + const response = NextResponse.json({ ok: true }); 10 + response.cookies.set("nav-state", state, { 11 + path: "/", 12 + sameSite: "lax", 13 + maxAge: 60 * 60 * 24 * 365, 14 + }); 15 + return response; 16 + }
+49 -9
app/lish/Subscribe.tsx
··· 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 26 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 27 + import { RSSTiny } from "components/Icons/RSSTiny"; 27 28 28 29 export const SubscribeWithBluesky = (props: { 30 + compact?: boolean; 29 31 pubName: string; 30 32 pub_uri: string; 31 33 base_url: string; ··· 36 38 let [successModalOpen, setSuccessModalOpen] = useState( 37 39 !!searchParams.has("showSubscribeSuccess"), 38 40 ); 41 + let [localSubscribeState, setLocalSubscribeState] = useState< 42 + "subscribed" | "unsubscribed" 43 + >("subscribed"); 39 44 let subscribed = 40 45 identity?.atp_did && 46 + localSubscribeState !== "unsubscribed" && 41 47 props.subscribers.find((s) => s.identity === identity.atp_did); 42 48 43 49 if (successModalOpen) ··· 48 54 /> 49 55 ); 50 56 if (subscribed) { 51 - return <ManageSubscription {...props} />; 57 + return ( 58 + <ManageSubscription 59 + {...props} 60 + onUnsubscribe={() => setLocalSubscribeState("unsubscribed")} 61 + /> 62 + ); 52 63 } 53 64 return ( 54 65 <div className="flex flex-col gap-2 text-center justify-center"> 55 66 <div className="flex flex-row gap-2 place-self-center"> 56 67 <BlueskySubscribeButton 68 + setLocalSubscribeState={() => setLocalSubscribeState("subscribed")} 69 + compact={props.compact} 57 70 pub_uri={props.pub_uri} 58 71 setSuccessModalOpen={setSuccessModalOpen} 59 72 /> ··· 63 76 target="_blank" 64 77 aria-label="Subscribe to RSS" 65 78 > 66 - <RSSSmall className="self-center" aria-hidden /> 79 + {props.compact ? ( 80 + <RSSTiny className="self-center" aria-hidden /> 81 + ) : ( 82 + <RSSSmall className="self-center" aria-hidden /> 83 + )} 67 84 </a> 68 85 </div> 69 86 </div> ··· 74 91 pub_uri: string; 75 92 subscribers: { identity: string }[]; 76 93 base_url: string; 94 + compact?: boolean; 95 + onUnsubscribe?: () => void; 77 96 }) => { 78 97 let toaster = useToaster(); 79 98 let [hasFeed] = useState(false); ··· 83 102 content: "You unsubscribed.", 84 103 type: "success", 85 104 }); 105 + props.onUnsubscribe?.(); 86 106 }, null); 87 107 return ( 88 108 <Popover 89 109 trigger={ 90 - <div className="text-accent-contrast text-sm">Manage Subscription</div> 110 + <div 111 + className={`text-accent-contrast w-fit ${props.compact ? "text-xs" : "text-sm"}`} 112 + > 113 + Manage Subscription 114 + </div> 91 115 } 92 116 > 93 - <div className="max-w-sm flex flex-col gap-1"> 117 + <div 118 + className={`max-w-sm flex flex-col gap-1 ${props.compact && "text-sm"}`} 119 + > 94 120 <h4>Update Options</h4> 95 121 96 122 {!hasFeed && ( ··· 100 126 className=" place-self-center" 101 127 > 102 128 <ButtonPrimary fullWidth compact className="!px-4"> 103 - View Bluesky Custom Feed 129 + Bluesky Custom Feed 104 130 </ButtonPrimary> 105 131 </a> 106 132 )} ··· 119 145 <hr className="border-border-light my-1" /> 120 146 121 147 <form action={unsubscribe}> 122 - <button className="font-bold text-accent-contrast w-max place-self-center"> 123 - {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 148 + <button className="font-bold w-full text-accent-contrast text-center mx-auto"> 149 + {unsubscribePending ? ( 150 + <DotLoader className="w-fit mx-auto" /> 151 + ) : ( 152 + "Unsubscribe" 153 + )} 124 154 </button> 125 155 </form> 126 156 </div> ··· 131 161 let BlueskySubscribeButton = (props: { 132 162 pub_uri: string; 133 163 setSuccessModalOpen: (open: boolean) => void; 164 + compact?: boolean; 165 + setLocalSubscribeState: () => void; 134 166 }) => { 135 167 let { identity } = useIdentityData(); 136 168 let toaster = useToaster(); ··· 153 185 props.setSuccessModalOpen(true); 154 186 } 155 187 toaster({ content: <div>You're Subscribed!</div>, type: "success" }); 188 + props.setLocalSubscribeState(); 156 189 }, null); 157 190 158 191 let [isClient, setIsClient] = useState(false); ··· 164 197 return ( 165 198 <Popover 166 199 asChild 200 + className="max-w-xs" 167 201 trigger={ 168 - <ButtonPrimary className="place-self-center"> 202 + <ButtonPrimary 203 + compact={props.compact} 204 + className={`place-self-center ${props.compact && "text-sm"}`} 205 + > 169 206 <BlueskyTiny /> Subscribe with Bluesky 170 207 </ButtonPrimary> 171 208 } ··· 188 225 action={subscribe} 189 226 className="place-self-center flex flex-row gap-1" 190 227 > 191 - <ButtonPrimary> 228 + <ButtonPrimary 229 + compact={props.compact} 230 + className={props.compact ? "text-sm" : ""} 231 + > 192 232 {subscribePending ? ( 193 233 <DotLoader /> 194 234 ) : (
+3 -17
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
··· 1 1 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 - import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 2 + import { AppBskyFeedDefs } from "@atproto/api"; 3 3 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 4 4 import { BskyPostContent } from "../BskyPostContent"; 5 5 ··· 7 7 post: PostView; 8 8 className: string; 9 9 pageId?: string; 10 + clientHost?: string; 10 11 }) => { 11 12 let post = props.post; 12 13 ··· 21 22 ); 22 23 23 24 case AppBskyFeedDefs.validatePostView(post).success: 24 - let record = post.record as AppBskyFeedDefs.PostView["record"]; 25 - 26 - // silliness to get the text and timestamp from the record with proper types 27 - let timestamp: string | undefined = undefined; 28 - if (AppBskyFeedPost.isRecord(record)) { 29 - timestamp = (record as AppBskyFeedPost.Record).createdAt; 30 - } 31 - 32 - //getting the url to the post 33 - let postId = post.uri.split("/")[4]; 34 25 let postView = post as PostView; 35 - 36 - let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 37 - 38 - const parent = props.pageId 39 - ? { type: "doc" as const, id: props.pageId } 40 - : undefined; 41 26 42 27 return ( 43 28 <BskyPostContent ··· 49 34 quoteEnabled 50 35 replyEnabled 51 36 className="text-sm text-secondary block-border sm:px-3 sm:py-2 px-2 py-1 bg-bg-page mb-2 hover:border-accent-contrast!" 37 + clientHost={props.clientHost} 52 38 /> 53 39 ); 54 40 }
+6 -3
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 28 28 quoteEnabled?: boolean; 29 29 replyEnabled?: boolean; 30 30 replyOnClick?: (e: React.MouseEvent) => void; 31 + clientHost?: string; 31 32 }) { 32 33 const { 33 34 post, ··· 39 40 quoteEnabled, 40 41 replyEnabled, 41 42 replyOnClick, 43 + clientHost = "bsky.app", 42 44 } = props; 43 45 44 46 const record = post.record as AppBskyFeedPost.Record; 45 47 const postId = post.uri.split("/")[4]; 46 - const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 48 + const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; 47 49 48 50 return ( 49 51 <div className={`bskyPost relative flex flex-col w-full `}> ··· 138 140 quoteEnabled?: boolean; 139 141 replyEnabled?: boolean; 140 142 replyOnClick?: (e: React.MouseEvent) => void; 143 + clientHost?: string; 141 144 }) { 142 - const { post, parent, quoteEnabled, replyEnabled, replyOnClick } = props; 145 + const { post, parent, quoteEnabled, replyEnabled, replyOnClick, clientHost = "bsky.app" } = props; 143 146 144 147 const record = post.record as AppBskyFeedPost.Record; 145 148 const postId = post.uri.split("/")[4]; 146 - const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 149 + const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; 147 150 148 151 return ( 149 152 <div className="bskyPost relative flex flex-col w-full">
+6
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 71 71 preferences={preferences} 72 72 commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 73 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 + recommendsCount={document.recommendsCount} 74 75 /> 75 76 <CanvasContent 76 77 blocks={blocks} ··· 205 206 preferences: { 206 207 showComments?: boolean; 207 208 showMentions?: boolean; 209 + showRecommends?: boolean; 208 210 showPrevNext?: boolean; 209 211 }; 210 212 quotesCount: number | undefined; 211 213 commentsCount: number | undefined; 214 + recommendsCount: number; 212 215 }) => { 213 216 let isMobile = useIsMobile(); 214 217 return ( ··· 216 219 <Interactions 217 220 quotesCount={props.quotesCount || 0} 218 221 commentsCount={props.commentsCount || 0} 222 + recommendsCount={props.recommendsCount} 219 223 showComments={props.preferences.showComments !== false} 220 224 showMentions={props.preferences.showMentions !== false} 225 + showRecommends={props.preferences.showRecommends !== false} 221 226 pageId={props.pageId} 222 227 /> 223 228 {!props.isSubpage && ( ··· 233 238 data={props.data} 234 239 profile={props.profile} 235 240 preferences={props.preferences} 241 + isCanvas 236 242 /> 237 243 </Popover> 238 244 </>
+2 -1
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 21 21 } from "src/utils/normalizeRecords"; 22 22 import { DocumentProvider } from "contexts/DocumentContext"; 23 23 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 24 + import { mergePreferences } from "src/utils/mergePreferences"; 24 25 25 26 export async function DocumentPageRenderer({ 26 27 did, ··· 133 134 <LeafletLayout> 134 135 <PostPages 135 136 document_uri={document.uri} 136 - preferences={pubRecord?.preferences || {}} 137 + preferences={mergePreferences(record?.preferences, pubRecord?.preferences)} 137 138 pubRecord={pubRecord} 138 139 profile={JSON.parse(JSON.stringify(profile.data))} 139 140 document={document}
+13 -19
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 29 29 document_uri: string; 30 30 comments: Comment[]; 31 31 pageId?: string; 32 + noCommentBox?: boolean; 32 33 }) { 33 34 let { identity } = useIdentityData(); 34 35 let { localComments } = useInteractionState(props.document_uri); ··· 55 56 id={"commentsDrawer"} 56 57 className="flex flex-col gap-2 relative text-sm text-secondary" 57 58 > 58 - <div className="w-full flex justify-between"> 59 - <h4> Comments</h4> 60 - <button 61 - className="text-tertiary" 62 - onClick={() => 63 - setInteractionState(props.document_uri, { drawerOpen: false }) 64 - } 65 - > 66 - <CloseTiny /> 67 - </button> 68 - </div> 69 - {identity?.atp_did ? ( 70 - <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 71 - ) : ( 72 - <div className="w-full accent-container text-tertiary text-center italic p-3 flex flex-col gap-2"> 73 - Connect a Bluesky account to comment 74 - <BlueskyLogin redirectRoute={redirectRoute} /> 75 - </div> 59 + {!props.noCommentBox && ( 60 + <> 61 + {identity?.atp_did ? ( 62 + <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 63 + ) : ( 64 + <div className="w-full accent-container text-tertiary text-center italic p-3 flex flex-col gap-2"> 65 + Connect a Bluesky account to comment 66 + <BlueskyLogin redirectRoute={redirectRoute} /> 67 + </div> 68 + )} 69 + <hr className="border-border-light" /> 70 + </> 76 71 )} 77 - <hr className="border-border-light" /> 78 72 <div className="flex flex-col gap-4 py-2"> 79 73 {comments 80 74 .sort((a, b) => {
+41 -11
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { Media } from "components/Media"; 3 3 import { MentionsDrawerContent } from "./Quotes"; 4 - import { InteractionState, useInteractionState } from "./Interactions"; 4 + import { 5 + InteractionState, 6 + setInteractionState, 7 + useInteractionState, 8 + } from "./Interactions"; 5 9 import { Json } from "supabase/database.types"; 6 10 import { Comment, CommentsDrawerContent } from "./Comments"; 7 11 import { useSearchParams } from "next/navigation"; 8 12 import { SandwichSpacer } from "components/LeafletLayout"; 9 13 import { decodeQuotePosition } from "../quotePosition"; 14 + import { CloseTiny } from "components/Icons/CloseTiny"; 10 15 11 16 export const InteractionDrawer = (props: { 12 17 showPageBackground: boolean | undefined; ··· 39 44 <div className="snap-center h-full flex z-10 shrink-0 sm:max-w-prose sm:w-full w-[calc(100vw-12px)]"> 40 45 <div 41 46 id="interaction-drawer" 42 - className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 47 + className={`opaque-container relative h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll flex flex-col ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 43 48 > 44 49 {drawer.drawer === "quotes" ? ( 45 - <MentionsDrawerContent 46 - {...props} 47 - quotesAndMentions={filteredQuotesAndMentions} 48 - /> 50 + <> 51 + <button 52 + className="text-tertiary absolute top-4 right-4" 53 + onClick={() => 54 + setInteractionState(props.document_uri, { drawerOpen: false }) 55 + } 56 + > 57 + <CloseTiny /> 58 + </button> 59 + <MentionsDrawerContent 60 + {...props} 61 + quotesAndMentions={filteredQuotesAndMentions} 62 + /> 63 + </> 49 64 ) : ( 50 - <CommentsDrawerContent 51 - document_uri={props.document_uri} 52 - comments={filteredComments} 53 - pageId={props.pageId} 54 - /> 65 + <> 66 + <div className="w-full flex justify-between"> 67 + <h4> Comments</h4> 68 + <button 69 + className="text-tertiary" 70 + onClick={() => 71 + setInteractionState(props.document_uri, { 72 + drawerOpen: false, 73 + }) 74 + } 75 + > 76 + <CloseTiny /> 77 + </button> 78 + </div> 79 + <CommentsDrawerContent 80 + document_uri={props.document_uri} 81 + comments={filteredComments} 82 + pageId={props.pageId} 83 + /> 84 + </> 55 85 )} 56 86 </div> 57 87 </div>
+83 -41
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 18 18 import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 19 19 import { EditTiny } from "components/Icons/EditTiny"; 20 20 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 21 + import { RecommendButton } from "components/RecommendButton"; 22 + import { ButtonSecondary } from "components/Buttons"; 23 + import { Separator } from "components/Layout"; 21 24 22 25 export type InteractionState = { 23 26 drawerOpen: undefined | boolean; ··· 105 108 export const Interactions = (props: { 106 109 quotesCount: number; 107 110 commentsCount: number; 111 + recommendsCount: number; 108 112 className?: string; 109 113 showComments: boolean; 110 114 showMentions: boolean; 115 + showRecommends: boolean; 111 116 pageId?: string; 112 117 }) => { 113 - const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); 118 + const { 119 + uri: document_uri, 120 + quotesAndMentions, 121 + normalizedDocument, 122 + } = useDocument(); 114 123 let { identity } = useIdentityData(); 115 124 116 125 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 124 133 const tags = normalizedDocument.tags; 125 134 const tagCount = tags?.length || 0; 126 135 136 + let interactionsAvailable = 137 + props.showComments || props.showMentions || props.showRecommends; 138 + 127 139 return ( 128 - <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 129 - {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 140 + <div 141 + className={`flex gap-[10px] text-tertiary text-sm item-center ${props.className}`} 142 + > 143 + {props.showRecommends === false ? null : ( 144 + <RecommendButton 145 + documentUri={document_uri} 146 + recommendsCount={props.recommendsCount} 147 + /> 148 + )} 130 149 150 + {/*MENTIONS BUTTON*/} 131 151 {props.quotesCount === 0 || props.showMentions === false ? null : ( 132 152 <button 133 - className="flex w-fit gap-2 items-center" 153 + className="flex w-fit gap-1 items-center" 134 154 onClick={() => { 135 155 if (!drawerOpen || drawer !== "quotes") 136 156 openInteractionDrawer("quotes", document_uri, props.pageId); ··· 143 163 <QuoteTiny aria-hidden /> {props.quotesCount} 144 164 </button> 145 165 )} 166 + {/*COMMENT BUTTON*/} 146 167 {props.showComments === false ? null : ( 147 168 <button 148 - className="flex gap-2 items-center w-fit" 169 + className="flex gap-1 items-center w-fit" 149 170 onClick={() => { 150 171 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 151 172 openInteractionDrawer("comments", document_uri, props.pageId); ··· 156 177 <CommentTiny aria-hidden /> {props.commentsCount} 157 178 </button> 158 179 )} 180 + 181 + {tagCount > 0 && ( 182 + <> 183 + {interactionsAvailable && <Separator classname="h-4!" />} 184 + <TagPopover tags={tags} tagCount={tagCount} /> 185 + </> 186 + )} 159 187 </div> 160 188 ); 161 189 }; ··· 163 191 export const ExpandedInteractions = (props: { 164 192 quotesCount: number; 165 193 commentsCount: number; 194 + recommendsCount: number; 166 195 className?: string; 167 196 showComments: boolean; 168 197 showMentions: boolean; 198 + showRecommends: boolean; 169 199 pageId?: string; 170 200 }) => { 171 - const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); 201 + const { 202 + uri: document_uri, 203 + quotesAndMentions, 204 + normalizedDocument, 205 + publication, 206 + leafletId, 207 + } = useDocument(); 172 208 let { identity } = useIdentityData(); 173 209 174 210 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 182 218 const tags = normalizedDocument.tags; 183 219 const tagCount = tags?.length || 0; 184 220 185 - let noInteractions = !props.showComments && !props.showMentions; 221 + let noInteractions = 222 + !props.showComments && !props.showMentions && !props.showRecommends; 186 223 187 224 let subscribed = 188 225 identity?.atp_did && ··· 191 228 (s) => s.identity === identity.atp_did, 192 229 ); 193 230 194 - let isAuthor = 195 - identity && 196 - identity.atp_did === publication?.identity_did && 197 - leafletId; 198 - 199 231 return ( 200 232 <div 201 233 className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} ··· 214 246 {noInteractions ? ( 215 247 <div /> 216 248 ) : ( 217 - <> 218 - <div className="flex gap-2"> 249 + <div className="flex flex-col gap-2 just"> 250 + <div className="flex gap-2 sm:flex-row flex-col"> 251 + {props.showRecommends === false ? null : ( 252 + <RecommendButton 253 + documentUri={document_uri} 254 + recommendsCount={props.recommendsCount} 255 + expanded 256 + /> 257 + )} 219 258 {props.quotesCount === 0 || !props.showMentions ? null : ( 220 - <button 221 - className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 259 + <ButtonSecondary 222 260 onClick={() => { 223 261 if (!drawerOpen || drawer !== "quotes") 224 262 openInteractionDrawer( ··· 233 271 onTouchStart={handleQuotePrefetch} 234 272 aria-label="Post quotes" 235 273 > 236 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 237 - <span 238 - aria-hidden 239 - >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 240 - </button> 274 + <QuoteTiny aria-hidden /> {props.quotesCount} 275 + <Separator classname="h-4! text-accent-contrast!" /> 276 + Mention{props.quotesCount > 1 ? "s" : ""} 277 + </ButtonSecondary> 241 278 )} 242 279 {!props.showComments ? null : ( 243 - <button 244 - className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 280 + <ButtonSecondary 245 281 onClick={() => { 246 282 if ( 247 283 !drawerOpen || ··· 259 295 aria-label="Post comments" 260 296 > 261 297 <CommentTiny aria-hidden />{" "} 262 - {props.commentsCount > 0 ? ( 263 - <span aria-hidden> 264 - {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 265 - </span> 266 - ) : ( 267 - "Comment" 298 + {props.commentsCount > 0 && ( 299 + <> 300 + {props.commentsCount} 301 + <Separator classname="h-4! text-accent-contrast!" /> 302 + </> 268 303 )} 269 - </button> 304 + Comment{props.commentsCount > 1 ? "s" : ""} 305 + </ButtonSecondary> 270 306 )} 271 307 </div> 272 - </> 308 + {subscribed && publication && ( 309 + <ManageSubscription 310 + base_url={getPublicationURL(publication)} 311 + pub_uri={publication.uri} 312 + subscribers={publication.publication_subscriptions} 313 + /> 314 + )} 315 + </div> 273 316 )} 274 317 275 318 <EditButton publication={publication} leafletId={leafletId} /> 276 - {subscribed && publication && ( 277 - <ManageSubscription 278 - base_url={getPublicationURL(publication)} 279 - pub_uri={publication.uri} 280 - subscribers={publication.publication_subscriptions} 281 - /> 282 - )} 283 319 </div> 284 320 </div> 285 321 ); ··· 313 349 </div> 314 350 ); 315 351 }; 316 - export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { 352 + export function getQuoteCount( 353 + quotesAndMentions: { uri: string; link?: string }[], 354 + pageId?: string, 355 + ) { 317 356 return getQuoteCountFromArray(quotesAndMentions, pageId); 318 357 } 319 358 ··· 338 377 } 339 378 } 340 379 341 - export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { 380 + export function getCommentCount( 381 + comments: CommentOnDocument[], 382 + pageId?: string, 383 + ) { 342 384 if (pageId) 343 385 return comments.filter( 344 386 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, ··· 362 404 return ( 363 405 <a 364 406 href={`https://leaflet.pub/${props.leafletId}`} 365 - className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 407 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-md !border-accent-1 !outline-accent-1 h-fit" 366 408 > 367 409 <EditTiny /> Edit Post 368 410 </a>
+2 -8
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 86 86 }); 87 87 88 88 return ( 89 - <div className="relative w-full flex justify-between "> 90 - <button 91 - className="text-tertiary absolute top-0 right-0" 92 - onClick={() => setInteractionState(document_uri, { drawerOpen: false })} 93 - > 94 - <CloseTiny /> 95 - </button> 89 + <> 96 90 {props.quotesAndMentions.length === 0 ? ( 97 91 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 98 92 <div className="font-bold">no quotes yet!</div> ··· 160 154 )} 161 155 </div> 162 156 )} 163 - </div> 157 + </> 164 158 ); 165 159 }; 166 160
+156
app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient, PubLeafletInteractionsRecommend } from "lexicons/api"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 + import { TID } from "@atproto/common"; 7 + import { AtUri, Un$Typed } from "@atproto/api"; 8 + import { supabaseServerClient } from "supabase/serverClient"; 9 + import { Json } from "supabase/database.types"; 10 + import { v7 } from "uuid"; 11 + import { 12 + Notification, 13 + pingIdentityToUpdateNotification, 14 + } from "src/notifications"; 15 + 16 + type RecommendResult = 17 + | { success: true; uri: string } 18 + | { 19 + success: false; 20 + error: OAuthSessionError | { type: string; message: string }; 21 + }; 22 + 23 + export async function recommendAction(args: { 24 + document: string; 25 + }): Promise<RecommendResult> { 26 + console.log("recommend action..."); 27 + let identity = await getIdentityData(); 28 + if (!identity || !identity.atp_did) { 29 + return { 30 + success: false, 31 + error: { 32 + type: "oauth_session_expired", 33 + message: "Not authenticated", 34 + did: "", 35 + }, 36 + }; 37 + } 38 + 39 + const sessionResult = await restoreOAuthSession(identity.atp_did); 40 + if (!sessionResult.ok) { 41 + return { success: false, error: sessionResult.error }; 42 + } 43 + let credentialSession = sessionResult.value; 44 + let agent = new AtpBaseClient( 45 + credentialSession.fetchHandler.bind(credentialSession), 46 + ); 47 + 48 + let record: Un$Typed<PubLeafletInteractionsRecommend.Record> = { 49 + subject: args.document, 50 + createdAt: new Date().toISOString(), 51 + }; 52 + 53 + let rkey = TID.nextStr(); 54 + let uri = AtUri.make( 55 + credentialSession.did!, 56 + "pub.leaflet.interactions.recommend", 57 + rkey, 58 + ); 59 + 60 + await agent.pub.leaflet.interactions.recommend.create( 61 + { rkey, repo: credentialSession.did! }, 62 + record, 63 + ); 64 + 65 + let res = await supabaseServerClient.from("recommends_on_documents").upsert({ 66 + uri: uri.toString(), 67 + document: args.document, 68 + recommender_did: credentialSession.did!, 69 + record: { 70 + $type: "pub.leaflet.interactions.recommend", 71 + ...record, 72 + } as unknown as Json, 73 + }); 74 + console.log(res); 75 + 76 + // Notify the document owner 77 + let documentOwner = new AtUri(args.document).host; 78 + if (documentOwner !== credentialSession.did) { 79 + let notification: Notification = { 80 + id: v7(), 81 + recipient: documentOwner, 82 + data: { 83 + type: "recommend", 84 + document_uri: args.document, 85 + recommend_uri: uri.toString(), 86 + }, 87 + }; 88 + await supabaseServerClient.from("notifications").insert(notification); 89 + await pingIdentityToUpdateNotification(documentOwner); 90 + } 91 + 92 + return { 93 + success: true, 94 + uri: uri.toString(), 95 + }; 96 + } 97 + 98 + export async function unrecommendAction(args: { 99 + document: string; 100 + }): Promise<RecommendResult> { 101 + let identity = await getIdentityData(); 102 + if (!identity || !identity.atp_did) { 103 + return { 104 + success: false, 105 + error: { 106 + type: "oauth_session_expired", 107 + message: "Not authenticated", 108 + did: "", 109 + }, 110 + }; 111 + } 112 + 113 + const sessionResult = await restoreOAuthSession(identity.atp_did); 114 + if (!sessionResult.ok) { 115 + return { success: false, error: sessionResult.error }; 116 + } 117 + let credentialSession = sessionResult.value; 118 + let agent = new AtpBaseClient( 119 + credentialSession.fetchHandler.bind(credentialSession), 120 + ); 121 + 122 + // Find the existing recommend record 123 + const { data: existingRecommend } = await supabaseServerClient 124 + .from("recommends_on_documents") 125 + .select("uri") 126 + .eq("document", args.document) 127 + .eq("recommender_did", credentialSession.did!) 128 + .single(); 129 + 130 + if (!existingRecommend) { 131 + return { 132 + success: false, 133 + error: { 134 + type: "not_found", 135 + message: "Recommend not found", 136 + }, 137 + }; 138 + } 139 + 140 + let uri = new AtUri(existingRecommend.uri); 141 + 142 + await agent.pub.leaflet.interactions.recommend.delete({ 143 + rkey: uri.rkey, 144 + repo: credentialSession.did!, 145 + }); 146 + 147 + await supabaseServerClient 148 + .from("recommends_on_documents") 149 + .delete() 150 + .eq("uri", existingRecommend.uri); 151 + 152 + return { 153 + success: true, 154 + uri: existingRecommend.uri, 155 + }; 156 + }
+5 -1
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 87 87 pageId={pageId} 88 88 showComments={preferences.showComments !== false} 89 89 showMentions={preferences.showMentions !== false} 90 - commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0} 90 + showRecommends={preferences.showRecommends !== false} 91 + commentsCount={ 92 + getCommentCount(document.comments_on_documents, pageId) || 0 93 + } 91 94 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 95 + recommendsCount={document.recommendsCount} 92 96 /> 93 97 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 94 98 </PageWrapper>
+3 -1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 32 32 import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 33 33 import { PollData } from "./fetchPollData"; 34 34 import { ButtonPrimary } from "components/Buttons"; 35 + import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 35 36 36 37 export function PostContent({ 37 38 blocks, ··· 170 171 case PubLeafletBlocksBskyPost.isMain(b.block): { 171 172 let uri = b.block.postRef.uri; 172 173 let post = bskyPostData.find((p) => p.uri === uri); 173 - if (!post) return <div>no prefetched post rip</div>; 174 + if (!post) return <PostNotAvailable />; 174 175 return ( 175 176 <PubBlueskyPostBlock 176 177 post={post} 177 178 className={className} 178 179 pageId={pageId} 180 + clientHost={b.block.clientHost} 179 181 /> 180 182 ); 181 183 }
+20 -7
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 18 18 export function PostHeader(props: { 19 19 data: PostPageData; 20 20 profile: ProfileViewDetailed; 21 - preferences: { showComments?: boolean; showMentions?: boolean }; 21 + preferences: { 22 + showComments?: boolean; 23 + showMentions?: boolean; 24 + showRecommends?: boolean; 25 + }; 26 + isCanvas?: boolean; 22 27 }) { 23 28 let { identity } = useIdentityData(); 24 29 let document = props.data; ··· 84 89 </> 85 90 ) : null} 86 91 </div> 87 - <Interactions 88 - showComments={props.preferences.showComments !== false} 89 - showMentions={props.preferences.showMentions !== false} 90 - quotesCount={getQuoteCount(document?.quotesAndMentions || []) || 0} 91 - commentsCount={getCommentCount(document?.comments_on_documents || []) || 0} 92 - /> 92 + {!props.isCanvas && ( 93 + <Interactions 94 + showComments={props.preferences.showComments !== false} 95 + showMentions={props.preferences.showMentions !== false} 96 + showRecommends={props.preferences.showRecommends !== false} 97 + quotesCount={ 98 + getQuoteCount(document?.quotesAndMentions || []) || 0 99 + } 100 + commentsCount={ 101 + getCommentCount(document?.comments_on_documents || []) || 0 102 + } 103 + recommendsCount={document?.recommendsCount || 0} 104 + /> 105 + )} 93 106 </> 94 107 } 95 108 />
+6 -4
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 170 170 preferences: { 171 171 showComments?: boolean; 172 172 showMentions?: boolean; 173 + showRecommends?: boolean; 173 174 showPrevNext?: boolean; 174 175 }; 175 176 pubRecord?: NormalizedPublication | null; ··· 233 234 preferences: { 234 235 showComments?: boolean; 235 236 showMentions?: boolean; 237 + showRecommends?: boolean; 236 238 showPrevNext?: boolean; 237 239 }; 238 240 pollData: PollData[]; ··· 293 295 showPageBackground={pubRecord?.theme?.showPageBackground} 294 296 document_uri={document.uri} 295 297 comments={ 296 - pubRecord?.preferences?.showComments === false 298 + preferences.showComments === false 297 299 ? [] 298 300 : document.comments_on_documents 299 301 } 300 302 quotesAndMentions={ 301 - pubRecord?.preferences?.showMentions === false 303 + preferences.showMentions === false 302 304 ? [] 303 305 : quotesAndMentions 304 306 } ··· 385 387 pageId={page.id} 386 388 document_uri={document.uri} 387 389 comments={ 388 - pubRecord?.preferences?.showComments === false 390 + preferences.showComments === false 389 391 ? [] 390 392 : document.comments_on_documents 391 393 } 392 394 quotesAndMentions={ 393 - pubRecord?.preferences?.showMentions === false 395 + preferences.showMentions === false 394 396 ? [] 395 397 : quotesAndMentions 396 398 }
+36 -19
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 3 3 import { 4 4 normalizeDocumentRecord, 5 5 normalizePublicationRecord, 6 - type NormalizedDocument, 7 - type NormalizedPublication, 8 6 } from "src/utils/normalizeRecords"; 9 7 import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 10 8 import { documentUriFilter } from "src/utils/uriHelpers"; 9 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 11 10 12 11 export async function getPostPageData(did: string, rkey: string) { 13 12 let { data: documents } = await supabaseServerClient ··· 22 21 publication_subscriptions(*)) 23 22 ), 24 23 document_mentions_in_bsky(*), 25 - leaflets_in_publications(*) 24 + leaflets_in_publications(*), 25 + recommends_on_documents(count) 26 26 `, 27 27 ) 28 28 .or(documentUriFilter(did, rkey)) ··· 33 33 if (!document) return null; 34 34 35 35 // Normalize the document record - this is the primary way consumers should access document data 36 - const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); 36 + const normalizedDocument = normalizeDocumentRecord( 37 + document.data, 38 + document.uri, 39 + ); 37 40 if (!normalizedDocument) return null; 38 41 39 42 // Normalize the publication record - this is the primary way consumers should access publication data 40 43 const normalizedPublication = normalizePublicationRecord( 41 - document.documents_in_publications[0]?.publications?.record 44 + document.documents_in_publications[0]?.publications?.record, 42 45 ); 43 46 44 47 // Fetch constellation backlinks for mentions 45 - let aturi = new AtUri(document.uri); 46 - const postUrl = normalizedPublication 47 - ? `${normalizedPublication.url}/${aturi.rkey}` 48 - : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; 49 - const constellationBacklinks = await getConstellationBacklinks(postUrl); 48 + const postUrl = getDocumentURL(normalizedDocument, document.uri, normalizedPublication); 49 + // Constellation needs an absolute URL 50 + const absolutePostUrl = postUrl.startsWith("/") 51 + ? `https://leaflet.pub${postUrl}` 52 + : postUrl; 53 + const constellationBacklinks = await getConstellationBacklinks(absolutePostUrl); 50 54 51 55 // Deduplicate constellation backlinks (same post could appear in both links and embeds) 52 56 const uniqueBacklinks = Array.from( ··· 83 87 // Filter and sort documents by publishedAt 84 88 const sortedDocs = allDocs 85 89 .map((dip) => { 86 - const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); 90 + const normalizedData = normalizeDocumentRecord( 91 + dip?.documents?.data, 92 + dip?.documents?.uri, 93 + ); 87 94 return { 88 95 uri: dip?.documents?.uri, 89 96 title: normalizedData?.title, ··· 98 105 ); 99 106 100 107 // Find current document index 101 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); 108 + const currentIndex = sortedDocs.findIndex( 109 + (doc) => doc.uri === document.uri, 110 + ); 102 111 103 112 if (currentIndex !== -1) { 104 113 prevNext = { ··· 122 131 123 132 // Build explicit publication context for consumers 124 133 const rawPub = document.documents_in_publications[0]?.publications; 125 - const publication = rawPub ? { 126 - uri: rawPub.uri, 127 - name: rawPub.name, 128 - identity_did: rawPub.identity_did, 129 - record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null, 130 - publication_subscriptions: rawPub.publication_subscriptions || [], 131 - } : null; 134 + const publication = rawPub 135 + ? { 136 + uri: rawPub.uri, 137 + name: rawPub.name, 138 + identity_did: rawPub.identity_did, 139 + record: rawPub.record as 140 + | PubLeafletPublication.Record 141 + | SiteStandardPublication.Record 142 + | null, 143 + publication_subscriptions: rawPub.publication_subscriptions || [], 144 + } 145 + : null; 146 + const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; 132 147 133 148 return { 134 149 ...document, ··· 143 158 comments: document.comments_on_documents, 144 159 mentions: document.document_mentions_in_bsky, 145 160 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 161 + // Recommends data 162 + recommendsCount, 146 163 }; 147 164 } 148 165
+1
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 45 45 icon=<ShareSmall /> 46 46 label="Share" 47 47 onClick={() => {}} 48 + smallOnMobile 48 49 /> 49 50 } 50 51 >
+2 -8
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 1 1 "use client"; 2 2 3 - import { NewDraftSecondaryButton } from "./NewDraftButton"; 4 3 import React from "react"; 5 4 import { 6 5 usePublicationData, ··· 22 21 if (!normalizedPubRecord) return null; 23 22 24 23 return ( 25 - <div className="flex flex-col gap-4"> 26 - <NewDraftSecondaryButton 27 - fullWidth 28 - publication={pub_data?.publication?.uri} 29 - /> 30 - 24 + <div className="flex flex-col"> 31 25 <LeafletList 32 26 searchValue={props.searchValue} 33 27 showPreview={false} ··· 60 54 ), 61 55 }} 62 56 /> 63 - <div className="spacer h-12 w-full bg-transparent shrink-0 " /> 57 + <div className="spacer h-16 w-full bg-transparent shrink-0 " /> 64 58 </div> 65 59 ); 66 60 }
+3 -23
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
··· 16 16 let newLeaflet = await createPublicationDraft(props.publication); 17 17 router.push(`/${newLeaflet}`); 18 18 }} 19 - icon=<AddTiny className="m-1 shrink-0" /> 20 - label="New" 19 + icon=<AddTiny className="sm:m-1 shrink-0 sm:scale-100 scale-75" /> 20 + smallOnMobile 21 + label="Draft" 21 22 /> 22 23 ); 23 24 } 24 - 25 - export function NewDraftSecondaryButton(props: { 26 - publication: string; 27 - fullWidth?: boolean; 28 - }) { 29 - let router = useRouter(); 30 - 31 - return ( 32 - <ButtonSecondary 33 - fullWidth={props.fullWidth} 34 - id="new-leaflet-button" 35 - onClick={async () => { 36 - let newLeaflet = await createPublicationDraft(props.publication); 37 - router.push(`/${newLeaflet}`); 38 - }} 39 - > 40 - <AddTiny className="m-1 shrink-0" /> 41 - <span>New Draft</span> 42 - </ButtonSecondary> 43 - ); 44 - }
+1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 81 81 actions={<Actions publication={publication.uri} />} 82 82 currentPage="pub" 83 83 publication={publication.uri} 84 + pageTitle={record.name} 84 85 /> 85 86 ); 86 87 }
+15 -11
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 9 9 } from "./PublicationSWRProvider"; 10 10 import { Fragment } from "react"; 11 11 import { useParams } from "next/navigation"; 12 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 13 13 import { SpeedyLink } from "components/SpeedyLink"; 14 14 import { InteractionPreview } from "components/InteractionsPreview"; 15 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; ··· 60 60 61 61 function PublishedPostItem(props: { 62 62 doc: PublishedDocument; 63 - publication: NonNullable<NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"]>; 63 + publication: NonNullable< 64 + NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"] 65 + >; 64 66 pubRecord: ReturnType<typeof useNormalizedPublicationRecord>; 65 67 showPageBackground: boolean; 66 68 }) { ··· 69 71 const leaflet = publication.leaflets_in_publications.find( 70 72 (l) => l.doc === doc.uri, 71 73 ); 74 + const docUrl = getDocumentURL(doc.record, doc.uri, publication); 72 75 73 76 return ( 74 77 <Fragment> ··· 85 88 <a 86 89 className="hover:no-underline!" 87 90 target="_blank" 88 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 91 + href={docUrl} 89 92 > 90 93 <h3 className="text-primary grow leading-snug"> 91 94 {doc.record.title} ··· 94 97 <div className="flex justify-start align-top flex-row gap-1"> 95 98 {leaflet && leaflet.permission_tokens && ( 96 99 <> 97 - <SpeedyLink 98 - className="pt-[6px]" 99 - href={`/${leaflet.leaflet}`} 100 - > 100 + <SpeedyLink className="pt-[6px]" href={`/${leaflet.leaflet}`}> 101 101 <EditTiny /> 102 102 </SpeedyLink> 103 103 ··· 113 113 indexed_at: doc.indexed_at, 114 114 sort_date: doc.sort_date, 115 115 data: doc.data, 116 + bsky_like_count: doc.bsky_like_count ?? 0, 117 + indexed: true, 118 + recommend_count: doc.recommendsCount ?? 0, 116 119 }, 117 120 }, 118 121 ], ··· 129 132 </div> 130 133 131 134 {doc.record.description ? ( 132 - <p className="italic text-secondary"> 133 - {doc.record.description} 134 - </p> 135 + <p className="italic text-secondary">{doc.record.description}</p> 135 136 ) : null} 136 137 <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 137 138 {doc.record.publishedAt ? ( ··· 140 141 <InteractionPreview 141 142 quotesCount={doc.mentionsCount} 142 143 commentsCount={doc.commentsCount} 144 + recommendsCount={doc.recommendsCount} 145 + documentUri={doc.uri} 143 146 tags={doc.record.tags || []} 144 147 showComments={pubRecord?.preferences?.showComments !== false} 145 148 showMentions={pubRecord?.preferences?.showMentions !== false} 146 - postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 149 + showRecommends={pubRecord?.preferences?.showRecommends !== false} 150 + postUrl={docUrl} 147 151 /> 148 152 </div> 149 153 </div>
+21 -1
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 29 29 ? true 30 30 : record.preferences.showMentions, 31 31 ); 32 + let [showRecommends, setShowRecommends] = useState( 33 + record?.preferences?.showRecommends === undefined 34 + ? true 35 + : record.preferences.showRecommends, 36 + ); 32 37 let [showPrevNext, setShowPrevNext] = useState( 33 38 record?.preferences?.showPrevNext === undefined 34 39 ? true ··· 53 58 showComments: showComments, 54 59 showMentions: showMentions, 55 60 showPrevNext: showPrevNext, 61 + showRecommends: showRecommends, 56 62 }, 57 63 }); 58 64 toast({ type: "success", content: <strong>Posts Updated!</strong> }); ··· 98 104 <div className="flex flex-col justify-start"> 99 105 <div className="font-bold">Show Mentions</div> 100 106 <div className="text-tertiary text-sm leading-tight"> 101 - Display a list of posts on Bluesky that mention your post 107 + Display a list of Bluesky mentions about your post 108 + </div> 109 + </div> 110 + </Toggle> 111 + 112 + <Toggle 113 + toggle={showRecommends} 114 + onToggle={() => { 115 + setShowRecommends(!showRecommends); 116 + }} 117 + > 118 + <div className="flex flex-col justify-start"> 119 + <div className="font-bold">Show Recommends</div> 120 + <div className="text-tertiary text-sm leading-tight"> 121 + Allow readers to recommend/like your post 102 122 </div> 103 123 </div> 104 124 </Toggle>
+1
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 43 43 id="pub-settings-button" 44 44 icon=<SettingsSmall /> 45 45 label="Settings" 46 + smallOnMobile 46 47 /> 47 48 } 48 49 >
+46 -37
app/lish/[did]/[publication]/generateFeed.ts
··· 11 11 hasLeafletContent, 12 12 } from "src/utils/normalizeRecords"; 13 13 import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 14 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 14 15 15 16 export async function generateFeed( 16 17 did: string, ··· 52 53 }, 53 54 }); 54 55 55 - await Promise.all( 56 - publication.documents_in_publications.map(async (doc) => { 57 - if (!doc.documents) return; 58 - const record = normalizeDocumentRecord( 59 - doc.documents?.data, 60 - doc.documents?.uri, 61 - ); 62 - const uri = new AtUri(doc.documents?.uri); 63 - const rkey = uri.rkey; 64 - if (!record) return; 56 + let docs = publication.documents_in_publications.sort((a, b) => { 57 + const dateA = a.documents?.sort_date 58 + ? new Date(a.documents.sort_date).getTime() 59 + : 0; 60 + const dateB = b.documents?.sort_date 61 + ? new Date(b.documents.sort_date).getTime() 62 + : 0; 63 + return dateB - dateA; // Sort in descending order (newest first) 64 + }); 65 + for (const doc of docs) { 66 + if (!doc.documents) continue; 67 + const record = normalizeDocumentRecord( 68 + doc.documents?.data, 69 + doc.documents?.uri, 70 + ); 71 + const uri = new AtUri(doc.documents?.uri); 72 + const rkey = uri.rkey; 73 + if (!record) continue; 65 74 66 - let blocks: PubLeafletPagesLinearDocument.Block[] = []; 67 - if (hasLeafletContent(record) && record.content.pages[0]) { 68 - const firstPage = record.content.pages[0]; 69 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 70 - blocks = firstPage.blocks || []; 71 - } 75 + let blocks: PubLeafletPagesLinearDocument.Block[] = []; 76 + if (hasLeafletContent(record) && record.content.pages[0]) { 77 + const firstPage = record.content.pages[0]; 78 + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 79 + blocks = firstPage.blocks || []; 72 80 } 73 - const stream = await renderToReadableStream( 74 - createElement(StaticPostContent, { blocks, did: uri.host }), 75 - ); 76 - const reader = stream.getReader(); 77 - const chunks = []; 81 + } 82 + const stream = await renderToReadableStream( 83 + createElement(StaticPostContent, { blocks, did: uri.host }), 84 + ); 85 + const reader = stream.getReader(); 86 + const chunks = []; 78 87 79 - let done, value; 80 - while (!done) { 81 - ({ done, value } = await reader.read()); 82 - if (value) { 83 - chunks.push(new TextDecoder().decode(value)); 84 - } 88 + let done, value; 89 + while (!done) { 90 + ({ done, value } = await reader.read()); 91 + if (value) { 92 + chunks.push(new TextDecoder().decode(value)); 85 93 } 94 + } 86 95 87 - feed.addItem({ 88 - title: record.title, 89 - description: record.description, 90 - date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 91 - id: `${pubRecord.url}/${rkey}`, 92 - link: `${pubRecord.url}/${rkey}`, 93 - content: chunks.join(""), 94 - }); 95 - }), 96 - ); 96 + const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord); 97 + feed.addItem({ 98 + title: record.title, 99 + description: record.description, 100 + date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 101 + id: docUrl, 102 + link: docUrl, 103 + content: chunks.join(""), 104 + }); 105 + } 97 106 98 107 return feed; 99 108 }
+18 -11
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 3 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 4 4 import { BskyAgent } from "@atproto/api"; 5 5 import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 6 6 import { SubscribeWithBluesky } from "app/lish/Subscribe"; ··· 38 38 documents_in_publications(documents( 39 39 *, 40 40 comments_on_documents(count), 41 - document_mentions_in_bsky(count) 41 + document_mentions_in_bsky(count), 42 + recommends_on_documents(count) 42 43 )) 43 44 `, 44 45 ) ··· 119 120 }) 120 121 .map((doc) => { 121 122 if (!doc.documents) return null; 122 - const doc_record = normalizeDocumentRecord(doc.documents.data); 123 + const doc_record = normalizeDocumentRecord( 124 + doc.documents.data, 125 + ); 123 126 if (!doc_record) return null; 124 127 let uri = new AtUri(doc.documents.uri); 125 128 let quotes = ··· 128 131 record?.preferences?.showComments === false 129 132 ? 0 130 133 : doc.documents.comments_on_documents[0].count || 0; 134 + let recommends = 135 + doc.documents.recommends_on_documents?.[0]?.count || 0; 131 136 let tags = doc_record.tags || []; 132 137 138 + const docUrl = getDocumentURL(doc_record, doc.documents.uri, publication); 133 139 return ( 134 140 <React.Fragment key={doc.documents?.uri}> 135 141 <div className="flex w-full grow flex-col "> 136 142 <SpeedyLink 137 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 143 + href={docUrl} 138 144 className="publishedPost hover:no-underline! flex flex-col" 139 145 > 140 146 <h3 className="text-primary">{doc_record.title}</h3> ··· 143 149 </p> 144 150 </SpeedyLink> 145 151 146 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 152 + <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 147 153 <p className="text-sm text-tertiary "> 148 154 {doc_record.publishedAt && ( 149 155 <LocalizedDate ··· 156 162 /> 157 163 )}{" "} 158 164 </p> 159 - {comments > 0 || quotes > 0 || tags.length > 0 ? ( 160 - <Separator classname="h-4! mx-1" /> 161 - ) : ( 162 - "" 163 - )} 165 + 164 166 <InteractionPreview 165 167 quotesCount={quotes} 166 168 commentsCount={comments} 169 + recommendsCount={recommends} 170 + documentUri={doc.documents.uri} 167 171 tags={tags} 168 - postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 172 + postUrl={docUrl} 169 173 showComments={ 170 174 record?.preferences?.showComments !== false 171 175 } 172 176 showMentions={ 173 177 record?.preferences?.showMentions !== false 178 + } 179 + showRecommends={ 180 + record?.preferences?.showRecommends !== false 174 181 } 175 182 /> 176 183 </div>
+1
app/lish/createPub/CreatePubForm.tsx
··· 58 58 showComments: true, 59 59 showMentions: true, 60 60 showPrevNext: true, 61 + showRecommends: true, 61 62 }, 62 63 }); 63 64
+1 -2
app/lish/createPub/UpdatePubForm.tsx
··· 88 88 showComments: showComments, 89 89 showMentions: showMentions, 90 90 showPrevNext: showPrevNext, 91 + showRecommends: record?.preferences?.showRecommends ?? true, 91 92 }, 92 93 }); 93 94 toast({ type: "success", content: "Updated!" }); ··· 193 194 </p> 194 195 </div> 195 196 </Toggle> 196 - 197 - 198 197 </div> 199 198 </form> 200 199 );
+23 -9
app/lish/createPub/createPublication.ts
··· 5 5 PubLeafletPublication, 6 6 SiteStandardPublication, 7 7 } from "lexicons/api"; 8 - import { 9 - restoreOAuthSession, 10 - OAuthSessionError, 11 - } from "src/atproto-oauth"; 8 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 12 9 import { getIdentityData } from "actions/getIdentityData"; 13 10 import { supabaseServerClient } from "supabase/serverClient"; 14 11 import { Json } from "supabase/database.types"; ··· 76 73 77 74 // Build record based on publication type 78 75 let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 79 - let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined; 76 + let iconBlob: 77 + | Awaited< 78 + ReturnType<typeof agent.com.atproto.repo.uploadBlob> 79 + >["data"]["blob"] 80 + | undefined; 80 81 81 82 // Upload the icon if provided 82 83 if (iconFile && iconFile.size > 0) { ··· 97 98 ...(iconBlob && { icon: iconBlob }), 98 99 basicTheme: { 99 100 $type: "site.standard.theme.basic", 100 - background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background }, 101 - foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground }, 102 - accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent }, 103 - accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground }, 101 + background: { 102 + $type: "site.standard.theme.color#rgb", 103 + ...PubThemeDefaultsRGB.background, 104 + }, 105 + foreground: { 106 + $type: "site.standard.theme.color#rgb", 107 + ...PubThemeDefaultsRGB.foreground, 108 + }, 109 + accent: { 110 + $type: "site.standard.theme.color#rgb", 111 + ...PubThemeDefaultsRGB.accent, 112 + }, 113 + accentForeground: { 114 + $type: "site.standard.theme.color#rgb", 115 + ...PubThemeDefaultsRGB.accentForeground, 116 + }, 104 117 }, 105 118 preferences: { 106 119 showInDiscover: preferences.showInDiscover, 107 120 showComments: preferences.showComments, 108 121 showMentions: preferences.showMentions, 109 122 showPrevNext: preferences.showPrevNext, 123 + showRecommends: preferences.showRecommends, 110 124 }, 111 125 } satisfies SiteStandardPublication.Record; 112 126 } else {
+30 -1
app/lish/createPub/getPublicationURL.ts
··· 5 5 import { 6 6 normalizePublicationRecord, 7 7 isLeafletPublication, 8 + type NormalizedDocument, 8 9 type NormalizedPublication, 9 10 } from "src/utils/normalizeRecords"; 10 11 ··· 20 21 const normalized = normalizePublicationRecord(pub.record); 21 22 22 23 // If we have a normalized record with a URL (site.standard format), use it 23 - if (normalized?.url && isProductionDomain()) { 24 + if (normalized?.url) { 24 25 return normalized.url; 25 26 } 26 27 ··· 44 45 const name = aturi.rkey || normalized?.name; 45 46 return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; 46 47 } 48 + 49 + /** 50 + * Gets the full URL for a document. 51 + * Always appends the document's path property. 52 + */ 53 + export function getDocumentURL( 54 + doc: NormalizedDocument, 55 + docUri: string, 56 + publication?: PublicationInput | NormalizedPublication | null, 57 + ): string { 58 + let path = doc.path || "/" + new AtUri(docUri).rkey; 59 + if (path[0] !== "/") path = "/" + path; 60 + 61 + if (!publication) { 62 + return doc.site + path; 63 + } 64 + 65 + // Already-normalized publications: use URL directly 66 + if ( 67 + (publication as NormalizedPublication).$type === 68 + "site.standard.publication" 69 + ) { 70 + return ((publication as NormalizedPublication).url || doc.site) + path; 71 + } 72 + 73 + // Raw publication input: delegate to getPublicationURL for full resolution 74 + return getPublicationURL(publication as PublicationInput) + path; 75 + }
+167 -98
app/lish/createPub/updatePublication.ts
··· 77 77 } 78 78 79 79 const aturi = new AtUri(existingPub.uri); 80 - const publicationType = getPublicationType(aturi.collection) as PublicationType; 80 + const publicationType = getPublicationType( 81 + aturi.collection, 82 + ) as PublicationType; 81 83 82 84 // Normalize existing record 83 85 const normalizedPub = normalizePublicationRecord(existingPub.record); ··· 128 130 } 129 131 130 132 /** Merges override with existing value, respecting explicit undefined */ 131 - function resolveField<T>(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { 133 + function resolveField<T>( 134 + override: T | undefined, 135 + existing: T | undefined, 136 + hasOverride: boolean, 137 + ): T | undefined { 132 138 return hasOverride ? override : existing; 133 139 } 134 140 ··· 146 152 return { 147 153 $type: "pub.leaflet.publication", 148 154 name: overrides.name ?? normalizedPub?.name ?? "", 149 - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 150 - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 151 - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 155 + description: resolveField( 156 + overrides.description, 157 + normalizedPub?.description, 158 + "description" in overrides, 159 + ), 160 + icon: resolveField( 161 + overrides.icon, 162 + normalizedPub?.icon, 163 + "icon" in overrides, 164 + ), 165 + theme: resolveField( 166 + overrides.theme, 167 + normalizedPub?.theme, 168 + "theme" in overrides, 169 + ), 152 170 base_path: overrides.basePath ?? existingBasePath, 153 - preferences: preferences ? { 154 - $type: "pub.leaflet.publication#preferences", 155 - showInDiscover: preferences.showInDiscover, 156 - showComments: preferences.showComments, 157 - showMentions: preferences.showMentions, 158 - showPrevNext: preferences.showPrevNext, 159 - } : undefined, 171 + preferences: preferences 172 + ? { 173 + $type: "pub.leaflet.publication#preferences", 174 + showInDiscover: preferences.showInDiscover, 175 + showComments: preferences.showComments, 176 + showMentions: preferences.showMentions, 177 + showPrevNext: preferences.showPrevNext, 178 + showRecommends: preferences.showRecommends, 179 + } 180 + : undefined, 160 181 }; 161 182 } 162 183 ··· 175 196 return { 176 197 $type: "site.standard.publication", 177 198 name: overrides.name ?? normalizedPub?.name ?? "", 178 - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 179 - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 180 - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 181 - basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), 199 + description: resolveField( 200 + overrides.description, 201 + normalizedPub?.description, 202 + "description" in overrides, 203 + ), 204 + icon: resolveField( 205 + overrides.icon, 206 + normalizedPub?.icon, 207 + "icon" in overrides, 208 + ), 209 + theme: resolveField( 210 + overrides.theme, 211 + normalizedPub?.theme, 212 + "theme" in overrides, 213 + ), 214 + basicTheme: resolveField( 215 + overrides.basicTheme, 216 + normalizedPub?.basicTheme, 217 + "basicTheme" in overrides, 218 + ), 182 219 url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 183 - preferences: preferences ? { 184 - showInDiscover: preferences.showInDiscover, 185 - showComments: preferences.showComments, 186 - showMentions: preferences.showMentions, 187 - showPrevNext: preferences.showPrevNext, 188 - } : undefined, 220 + preferences: preferences 221 + ? { 222 + showInDiscover: preferences.showInDiscover, 223 + showComments: preferences.showComments, 224 + showMentions: preferences.showMentions, 225 + showPrevNext: preferences.showPrevNext, 226 + showRecommends: preferences.showRecommends, 227 + } 228 + : undefined, 189 229 }; 190 230 } 191 231 ··· 217 257 iconFile?: File | null; 218 258 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 219 259 }): Promise<UpdatePublicationResult> { 220 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 221 - // Upload icon if provided 222 - let iconBlob = normalizedPub?.icon; 223 - if (iconFile && iconFile.size > 0) { 224 - const buffer = await iconFile.arrayBuffer(); 225 - const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 - new Uint8Array(buffer), 227 - { encoding: iconFile.type }, 228 - ); 229 - if (uploadResult.data.blob) { 230 - iconBlob = uploadResult.data.blob; 260 + return withPublicationUpdate( 261 + uri, 262 + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 263 + // Upload icon if provided 264 + let iconBlob = normalizedPub?.icon; 265 + if (iconFile && iconFile.size > 0) { 266 + const buffer = await iconFile.arrayBuffer(); 267 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 268 + new Uint8Array(buffer), 269 + { encoding: iconFile.type }, 270 + ); 271 + if (uploadResult.data.blob) { 272 + iconBlob = uploadResult.data.blob; 273 + } 231 274 } 232 - } 233 275 234 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 235 - name, 236 - description, 237 - icon: iconBlob, 238 - preferences, 239 - }); 240 - }); 276 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 277 + name, 278 + ...(description !== undefined && { description }), 279 + icon: iconBlob, 280 + preferences, 281 + }); 282 + }, 283 + ); 241 284 } 242 285 243 286 export async function updatePublicationBasePath({ ··· 247 290 uri: string; 248 291 base_path: string; 249 292 }): Promise<UpdatePublicationResult> { 250 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 251 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 252 - basePath: base_path, 253 - }); 254 - }); 293 + return withPublicationUpdate( 294 + uri, 295 + async ({ normalizedPub, existingBasePath, publicationType }) => { 296 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 297 + basePath: base_path, 298 + }); 299 + }, 300 + ); 255 301 } 256 302 257 303 type Color = ··· 275 321 accentText: Color; 276 322 }; 277 323 }): Promise<UpdatePublicationResult> { 278 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 - // Build theme object 280 - const themeData = { 281 - $type: "pub.leaflet.publication#theme" as const, 282 - backgroundImage: theme.backgroundImage 283 - ? { 284 - $type: "pub.leaflet.theme.backgroundImage", 285 - image: ( 286 - await agent.com.atproto.repo.uploadBlob( 287 - new Uint8Array(await theme.backgroundImage.arrayBuffer()), 288 - { encoding: theme.backgroundImage.type }, 289 - ) 290 - )?.data.blob, 291 - width: theme.backgroundRepeat || undefined, 292 - repeat: !!theme.backgroundRepeat, 293 - } 294 - : theme.backgroundImage === null 295 - ? undefined 296 - : normalizedPub?.theme?.backgroundImage, 297 - backgroundColor: theme.backgroundColor 298 - ? { 299 - ...theme.backgroundColor, 300 - } 301 - : undefined, 302 - pageWidth: theme.pageWidth, 303 - primary: { 304 - ...theme.primary, 305 - }, 306 - pageBackground: { 307 - ...theme.pageBackground, 308 - }, 309 - showPageBackground: theme.showPageBackground, 310 - accentBackground: { 311 - ...theme.accentBackground, 312 - }, 313 - accentText: { 314 - ...theme.accentText, 315 - }, 316 - }; 324 + return withPublicationUpdate( 325 + uri, 326 + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 327 + // Build theme object 328 + const themeData = { 329 + $type: "pub.leaflet.publication#theme" as const, 330 + backgroundImage: theme.backgroundImage 331 + ? { 332 + $type: "pub.leaflet.theme.backgroundImage", 333 + image: ( 334 + await agent.com.atproto.repo.uploadBlob( 335 + new Uint8Array(await theme.backgroundImage.arrayBuffer()), 336 + { encoding: theme.backgroundImage.type }, 337 + ) 338 + )?.data.blob, 339 + width: theme.backgroundRepeat || undefined, 340 + repeat: !!theme.backgroundRepeat, 341 + } 342 + : theme.backgroundImage === null 343 + ? undefined 344 + : normalizedPub?.theme?.backgroundImage, 345 + backgroundColor: theme.backgroundColor 346 + ? { 347 + ...theme.backgroundColor, 348 + } 349 + : undefined, 350 + pageWidth: theme.pageWidth, 351 + primary: { 352 + ...theme.primary, 353 + }, 354 + pageBackground: { 355 + ...theme.pageBackground, 356 + }, 357 + showPageBackground: theme.showPageBackground, 358 + accentBackground: { 359 + ...theme.accentBackground, 360 + }, 361 + accentText: { 362 + ...theme.accentText, 363 + }, 364 + }; 317 365 318 - // Derive basicTheme from the theme colors for site.standard.publication 319 - const basicTheme: NormalizedPublication["basicTheme"] = { 320 - $type: "site.standard.theme.basic", 321 - background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, 322 - foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, 323 - accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, 324 - accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, 325 - }; 366 + // Derive basicTheme from the theme colors for site.standard.publication 367 + const basicTheme: NormalizedPublication["basicTheme"] = { 368 + $type: "site.standard.theme.basic", 369 + background: { 370 + $type: "site.standard.theme.color#rgb", 371 + r: theme.backgroundColor.r, 372 + g: theme.backgroundColor.g, 373 + b: theme.backgroundColor.b, 374 + }, 375 + foreground: { 376 + $type: "site.standard.theme.color#rgb", 377 + r: theme.primary.r, 378 + g: theme.primary.g, 379 + b: theme.primary.b, 380 + }, 381 + accent: { 382 + $type: "site.standard.theme.color#rgb", 383 + r: theme.accentBackground.r, 384 + g: theme.accentBackground.g, 385 + b: theme.accentBackground.b, 386 + }, 387 + accentForeground: { 388 + $type: "site.standard.theme.color#rgb", 389 + r: theme.accentText.r, 390 + g: theme.accentText.g, 391 + b: theme.accentText.b, 392 + }, 393 + }; 326 394 327 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 328 - theme: themeData, 329 - basicTheme, 330 - }); 331 - }); 395 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 396 + theme: themeData, 397 + basicTheme, 398 + }); 399 + }, 400 + ); 332 401 }
+10 -13
app/lish/subscribeToPublication.ts
··· 3 3 import { AtpBaseClient } from "lexicons/api"; 4 4 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 5 import { getIdentityData } from "actions/getIdentityData"; 6 - import { 7 - restoreOAuthSession, 8 - OAuthSessionError, 9 - } from "src/atproto-oauth"; 6 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 10 7 import { TID } from "@atproto/common"; 11 8 import { supabaseServerClient } from "supabase/serverClient"; 12 9 import { revalidatePath } from "next/cache"; ··· 79 76 } 80 77 81 78 let bsky = new BskyAgent(credentialSession); 82 - let [prefs, profile, resolveDid] = await Promise.all([ 83 - bsky.app.bsky.actor.getPreferences(), 79 + let [profile, resolveDid] = await Promise.all([ 84 80 bsky.app.bsky.actor.profile 85 81 .get({ 86 82 repo: credentialSession.did!, ··· 96 92 handle: resolveDid?.alsoKnownAs?.[0]?.slice(5), 97 93 }); 98 94 } 99 - let savedFeeds = prefs.data.preferences.find( 100 - (pref) => pref.$type === "app.bsky.actor.defs#savedFeedsPrefV2", 101 - ) as AppBskyActorDefs.SavedFeedsPrefV2; 102 95 revalidatePath("/lish/[did]/[publication]", "layout"); 103 96 return { 104 97 success: true, 105 - hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), 98 + hasFeed: true, 106 99 }; 107 100 } 108 101 ··· 111 104 | { success: false; error: OAuthSessionError }; 112 105 113 106 export async function unsubscribeToPublication( 114 - publication: string 107 + publication: string, 115 108 ): Promise<UnsubscribeResult> { 116 109 let identity = await getIdentityData(); 117 110 if (!identity || !identity.atp_did) { ··· 144 137 // Delete from both collections (old and new schema) - one or both may exist 145 138 let rkey = new AtUri(existingSubscription.uri).rkey; 146 139 await Promise.all([ 147 - agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), 148 - agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), 140 + agent.pub.leaflet.graph.subscription 141 + .delete({ repo: credentialSession.did!, rkey }) 142 + .catch(() => {}), 143 + agent.site.standard.graph.subscription 144 + .delete({ repo: credentialSession.did!, rkey }) 145 + .catch(() => {}), 149 146 ]); 150 147 151 148 await supabaseServerClient
-11
app/route.ts
··· 1 - import { createNewLeaflet } from "actions/createNewLeaflet"; 2 - import { cookies } from "next/headers"; 3 - import { redirect } from "next/navigation"; 4 - 5 - export const preferredRegion = ["sfo1"]; 6 - export const dynamic = "force-dynamic"; 7 - export const fetchCache = "force-no-store"; 8 - 9 - export async function GET() { 10 - redirect("/home"); 11 - }
+18 -1
appview/index.ts
··· 109 109 data: record.value as Json, 110 110 }); 111 111 if (docResult.error) console.log(docResult.error); 112 + await inngest.send({ 113 + name: "appview/sync-document-metadata", 114 + data: { 115 + document_uri: evt.uri.toString(), 116 + bsky_post_uri: record.value.postRef?.uri, 117 + }, 118 + }); 112 119 if (record.value.publication) { 113 120 let publicationURI = new AtUri(record.value.publication); 114 121 ··· 269 276 data: record.value as Json, 270 277 }); 271 278 if (docResult.error) console.log(docResult.error); 279 + await inngest.send({ 280 + name: "appview/sync-document-metadata", 281 + data: { 282 + document_uri: evt.uri.toString(), 283 + bsky_post_uri: record.value.bskyPostRef?.uri, 284 + }, 285 + }); 272 286 273 287 // site.standard.document uses "site" field to reference the publication 274 288 // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey) ··· 377 391 378 392 // Now validate the record since we know it contains our quote param 379 393 let record = AppBskyFeedPost.validateRecord(evt.record); 380 - if (!record.success) return; 394 + if (!record.success) { 395 + console.log(record.error); 396 + return; 397 + } 381 398 382 399 let embed: string | null = null; 383 400 if (
+20 -13
components/ActionBar/ActionButton.tsx
··· 7 7 8 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 9 10 - export const ActionButton = ( 11 - _props: ButtonProps & { 10 + export const ActionButton = forwardRef< 11 + HTMLButtonElement, 12 + ButtonProps & { 12 13 id?: string; 13 14 icon: React.ReactNode; 14 15 label: React.ReactNode; ··· 18 19 className?: string; 19 20 subtext?: string; 20 21 labelOnMobile?: boolean; 22 + smallOnMobile?: boolean; 21 23 z?: boolean; 22 - }, 23 - ) => { 24 + } 25 + >((_props, ref) => { 24 26 let { 25 27 id, 26 28 icon, ··· 29 31 secondary, 30 32 nav, 31 33 labelOnMobile, 34 + smallOnMobile, 32 35 subtext, 33 36 className, 34 37 ...buttonProps ··· 50 53 return ( 51 54 <button 52 55 {...buttonProps} 56 + ref={ref} 53 57 className={` 54 58 actionButton relative font-bold 55 59 rounded-md border 56 - flex gap-2 items-start sm:justify-start justify-center 57 - p-1 sm:mx-0 58 - ${showLabelOnMobile && !secondary ? "w-full" : "sm:w-full w-max"} 60 + flex gap-2 items-center justify-start 61 + sm:w-full sm:max-w-full p-1 62 + w-max 63 + ${smallOnMobile && "sm:text-base text-sm py-0! sm:py-1! sm:h-fit h-6"} 59 64 ${ 60 65 primary 61 - ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0" 66 + ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 " 62 67 : secondary 63 - ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0" 68 + ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1" 64 69 : nav 65 - ? "border-transparent text-secondary sm:hover:border-border justify-start!" 70 + ? "border-transparent text-secondary sm:hover:border-border justify-start! max-w-full" 66 71 : "border-transparent text-accent-contrast sm:hover:border-accent-contrast" 67 72 } 68 73 ${className} 69 74 `} 70 75 > 71 - <div className="shrink-0">{icon}</div> 76 + <div className="shrink-0 flex flex-row gap-0.5">{icon}</div> 72 77 <div 73 - className={`flex flex-col pr-1 ${subtext && "leading-snug"} max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 78 + className={`flex flex-col ${subtext && "leading-snug"} sm:max-w-full min-w-0 mr-1 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 79 + style={{ width: "-webkit-fill-available" }} 74 80 > 75 81 <div className="truncate text-left">{label}</div> 76 82 {subtext && ( ··· 81 87 </div> 82 88 </button> 83 89 ); 84 - }; 90 + }); 91 + ActionButton.displayName = "ActionButton";
+66
components/ActionBar/DesktopNavigation.tsx
··· 1 + import { useIdentityData } from "components/IdentityProvider"; 2 + import { 3 + navPages, 4 + HomeButton, 5 + ReaderButton, 6 + NotificationButton, 7 + WriterButton, 8 + } from "./NavigationButtons"; 9 + import { PublicationButtons } from "./Publications"; 10 + import { Sidebar } from "./Sidebar"; 11 + import { LoginActionButton, LoginButton } from "components/LoginButton"; 12 + import { ProfileButton } from "./ProfileButton"; 13 + 14 + export const DesktopNavigation = (props: { 15 + currentPage: navPages; 16 + publication?: string; 17 + }) => { 18 + let { identity } = useIdentityData(); 19 + let thisPublication = identity?.publications?.find( 20 + (pub) => pub.uri === props.publication, 21 + ); 22 + 23 + let currentlyWriter = 24 + props.currentPage === "home" || 25 + props.currentPage === "looseleafs" || 26 + props.currentPage === "pub"; 27 + return ( 28 + <div className="flex flex-col gap-3"> 29 + <Sidebar alwaysOpen> 30 + {identity?.atp_did ? ( 31 + <> 32 + <ProfileButton /> 33 + <NotificationButton 34 + current={props.currentPage === "notifications"} 35 + /> 36 + </> 37 + ) : ( 38 + <LoginActionButton /> 39 + )} 40 + </Sidebar> 41 + 42 + <Sidebar alwaysOpen> 43 + <ReaderButton 44 + current={props.currentPage === "reader"} 45 + subs={ 46 + identity?.publication_subscriptions?.length !== 0 && 47 + identity?.publication_subscriptions?.length !== undefined 48 + } 49 + /> 50 + <WriterButton 51 + currentPage={props.currentPage} 52 + currentPubUri={thisPublication?.uri} 53 + /> 54 + {currentlyWriter && ( 55 + <> 56 + <hr className="border-border-light border-dashed" /> 57 + <PublicationButtons 58 + currentPage={props.currentPage} 59 + currentPubUri={thisPublication?.uri} 60 + /> 61 + </> 62 + )} 63 + </Sidebar> 64 + </div> 65 + ); 66 + };
+1 -1
components/ActionBar/Footer.tsx
··· 8 8 actionFooter touch-none shrink-0 9 9 w-full z-10 10 10 px-2 pt-1 pwa-padding-bottom 11 - flex justify-start gap-1 11 + flex justify-between 12 12 h-[calc(38px+var(--safe-padding-bottom))] 13 13 bg-[rgba(var(--bg-page),0.5)] border-top border-bg-page`} 14 14 >
+63
components/ActionBar/MobileNavigation.tsx
··· 1 + import { useIdentityData } from "components/IdentityProvider"; 2 + import { Separator } from "components/Layout"; 3 + import { 4 + navPages, 5 + NotificationButton, 6 + ReaderButton, 7 + WriterButton, 8 + } from "./NavigationButtons"; 9 + import { PublicationNavigation } from "./PublicationNavigation"; 10 + import { LoginActionButton } from "components/LoginButton"; 11 + import { ProfileButton } from "./ProfileButton"; 12 + 13 + export const MobileNavigation = (props: { 14 + currentPage: navPages; 15 + currentPublicationUri?: string; 16 + currentProfileDid?: string; 17 + }) => { 18 + let { identity } = useIdentityData(); 19 + 20 + let compactOnMobile = 21 + props.currentPage === "home" || 22 + props.currentPage === "looseleafs" || 23 + props.currentPage === "pub"; 24 + 25 + return ( 26 + <div 27 + className={`mobileFooter w-full flex gap-4 px-1 text-secondary grow items-center justify-between`} 28 + > 29 + <div className="mobileNav flex gap-2 items-center justify-start min-w-0"> 30 + <ReaderButton 31 + compactOnMobile={compactOnMobile} 32 + current={props.currentPage === "reader"} 33 + subs={ 34 + identity?.publication_subscriptions?.length !== 0 && 35 + identity?.publication_subscriptions?.length !== undefined 36 + } 37 + /> 38 + <WriterButton 39 + compactOnMobile={compactOnMobile} 40 + currentPage={props.currentPage} 41 + currentPubUri={props.currentPublicationUri} 42 + /> 43 + 44 + {compactOnMobile && ( 45 + <> 46 + <PublicationNavigation 47 + currentPage={props.currentPage} 48 + currentPubUri={props.currentPublicationUri} 49 + /> 50 + </> 51 + )} 52 + </div> 53 + {identity?.atp_did ? ( 54 + <div className="flex gap-2"> 55 + <NotificationButton /> 56 + <ProfileButton /> 57 + </div> 58 + ) : ( 59 + <LoginActionButton /> 60 + )} 61 + </div> 62 + ); 63 + };
-179
components/ActionBar/Navigation.tsx
··· 1 - import { HomeSmall } from "components/Icons/HomeSmall"; 2 - import { ActionButton } from "./ActionButton"; 3 - import { Sidebar } from "./Sidebar"; 4 - import { useIdentityData } from "components/IdentityProvider"; 5 - import Link from "next/link"; 6 - import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 7 - import { PublicationButtons } from "./Publications"; 8 - import { Popover } from "components/Popover"; 9 - import { MenuSmall } from "components/Icons/MenuSmall"; 10 - import { 11 - ReaderReadSmall, 12 - ReaderUnreadSmall, 13 - } from "components/Icons/ReaderSmall"; 14 - import { 15 - NotificationsReadSmall, 16 - NotificationsUnreadSmall, 17 - } from "components/Icons/NotificationSmall"; 18 - import { SpeedyLink } from "components/SpeedyLink"; 19 - import { Separator } from "components/Layout"; 20 - 21 - export type navPages = 22 - | "home" 23 - | "reader" 24 - | "pub" 25 - | "discover" 26 - | "notifications" 27 - | "looseleafs" 28 - | "tag" 29 - | "profile"; 30 - 31 - export const DesktopNavigation = (props: { 32 - currentPage: navPages; 33 - publication?: string; 34 - }) => { 35 - let { identity } = useIdentityData(); 36 - return ( 37 - <div className="flex flex-col gap-3"> 38 - <Sidebar alwaysOpen> 39 - <NavigationOptions 40 - currentPage={props.currentPage} 41 - publication={props.publication} 42 - /> 43 - </Sidebar> 44 - {identity?.atp_did && ( 45 - <Sidebar alwaysOpen> 46 - <NotificationButton current={props.currentPage === "notifications"} /> 47 - </Sidebar> 48 - )} 49 - </div> 50 - ); 51 - }; 52 - 53 - export const MobileNavigation = (props: { 54 - currentPage: navPages; 55 - publication?: string; 56 - }) => { 57 - let { identity } = useIdentityData(); 58 - 59 - return ( 60 - <div className="flex gap-1 "> 61 - <Popover 62 - onOpenAutoFocus={(e) => e.preventDefault()} 63 - asChild 64 - className="px-2! !max-w-[256px]" 65 - trigger={ 66 - <div className="shrink-0 p-1 text-accent-contrast h-full flex gap-2 font-bold items-center"> 67 - <MenuSmall /> 68 - </div> 69 - } 70 - > 71 - <NavigationOptions 72 - currentPage={props.currentPage} 73 - publication={props.publication} 74 - isMobile 75 - /> 76 - </Popover> 77 - {identity?.atp_did && ( 78 - <> 79 - <Separator /> 80 - <NotificationButton /> 81 - </> 82 - )} 83 - </div> 84 - ); 85 - }; 86 - 87 - const NavigationOptions = (props: { 88 - currentPage: navPages; 89 - publication?: string; 90 - isMobile?: boolean; 91 - }) => { 92 - let { identity } = useIdentityData(); 93 - let thisPublication = identity?.publications?.find( 94 - (pub) => pub.uri === props.publication, 95 - ); 96 - return ( 97 - <> 98 - <HomeButton current={props.currentPage === "home"} /> 99 - <ReaderButton 100 - current={props.currentPage === "reader"} 101 - subs={ 102 - identity?.publication_subscriptions?.length !== 0 && 103 - identity?.publication_subscriptions?.length !== undefined 104 - } 105 - /> 106 - <DiscoverButton current={props.currentPage === "discover"} /> 107 - 108 - <hr className="border-border-light my-1" /> 109 - <PublicationButtons 110 - currentPage={props.currentPage} 111 - currentPubUri={thisPublication?.uri} 112 - /> 113 - </> 114 - ); 115 - }; 116 - 117 - const HomeButton = (props: { current?: boolean }) => { 118 - return ( 119 - <SpeedyLink href={"/home"} className="hover:!no-underline"> 120 - <ActionButton 121 - nav 122 - icon={<HomeSmall />} 123 - label="Home" 124 - className={props.current ? "bg-bg-page! border-border-light!" : ""} 125 - /> 126 - </SpeedyLink> 127 - ); 128 - }; 129 - 130 - const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 131 - if (!props.subs) return; 132 - return ( 133 - <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 - <ActionButton 135 - nav 136 - icon={<ReaderUnreadSmall />} 137 - label="Reader" 138 - className={props.current ? "bg-bg-page! border-border-light!" : ""} 139 - /> 140 - </SpeedyLink> 141 - ); 142 - }; 143 - 144 - const DiscoverButton = (props: { current?: boolean }) => { 145 - return ( 146 - <Link href={"/discover"} className="hover:no-underline!"> 147 - <ActionButton 148 - nav 149 - icon={<DiscoverSmall />} 150 - label="Discover" 151 - subtext="" 152 - className={props.current ? "bg-bg-page! border-border-light!" : ""} 153 - /> 154 - </Link> 155 - ); 156 - }; 157 - 158 - export function NotificationButton(props: { current?: boolean }) { 159 - let { identity } = useIdentityData(); 160 - let unreads = identity?.notifications[0]?.count; 161 - 162 - return ( 163 - <SpeedyLink href={"/notifications"} className="hover:no-underline!"> 164 - <ActionButton 165 - nav 166 - labelOnMobile={false} 167 - icon={ 168 - unreads ? ( 169 - <NotificationsUnreadSmall className="text-accent-contrast" /> 170 - ) : ( 171 - <NotificationsReadSmall /> 172 - ) 173 - } 174 - label="Notifications" 175 - className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`} 176 - /> 177 - </SpeedyLink> 178 - ); 179 - }
+101
components/ActionBar/NavigationButtons.tsx
··· 1 + import { HomeSmall } from "components/Icons/HomeSmall"; 2 + import { ActionButton } from "./ActionButton"; 3 + import { useIdentityData } from "components/IdentityProvider"; 4 + import { PublicationButtons } from "./Publications"; 5 + import { ReaderUnreadSmall } from "components/Icons/ReaderSmall"; 6 + import { 7 + NotificationsReadSmall, 8 + NotificationsUnreadSmall, 9 + } from "components/Icons/NotificationSmall"; 10 + import { SpeedyLink } from "components/SpeedyLink"; 11 + import { Popover } from "components/Popover"; 12 + import { WriterSmall } from "components/Icons/WriterSmall"; 13 + export type navPages = 14 + | "home" 15 + | "reader" 16 + | "pub" 17 + | "notifications" 18 + | "looseleafs" 19 + | "tag" 20 + | "profile" 21 + | "discover"; 22 + 23 + export const HomeButton = (props: { 24 + current?: boolean; 25 + className?: string; 26 + }) => { 27 + return ( 28 + <SpeedyLink href={"/home"} className="hover:!no-underline"> 29 + <ActionButton 30 + nav 31 + icon={<HomeSmall />} 32 + label="Home" 33 + className={`${props.current ? "bg-bg-page! border-border-light!" : ""} w-full! ${props.className}`} 34 + /> 35 + </SpeedyLink> 36 + ); 37 + }; 38 + 39 + export const WriterButton = (props: { 40 + currentPage: navPages; 41 + currentPubUri?: string; 42 + compactOnMobile?: boolean; 43 + }) => { 44 + let current = 45 + props.currentPage === "home" || 46 + props.currentPage === "looseleafs" || 47 + props.currentPage === "pub"; 48 + 49 + return ( 50 + <SpeedyLink href={"/home"} className="hover:!no-underline"> 51 + <ActionButton 52 + nav 53 + labelOnMobile={!props.compactOnMobile} 54 + icon={<WriterSmall />} 55 + label="Write" 56 + className={`${current ? "bg-bg-page! border-border-light!" : ""}`} 57 + /> 58 + </SpeedyLink> 59 + ); 60 + }; 61 + 62 + export const ReaderButton = (props: { 63 + current?: boolean; 64 + subs: boolean; 65 + compactOnMobile?: boolean; 66 + }) => { 67 + return ( 68 + <SpeedyLink href={"/reader"} className="hover:no-underline!"> 69 + <ActionButton 70 + nav 71 + labelOnMobile={!props.compactOnMobile} 72 + icon={<ReaderUnreadSmall />} 73 + label="Read" 74 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 75 + /> 76 + </SpeedyLink> 77 + ); 78 + }; 79 + 80 + export function NotificationButton(props: { current?: boolean }) { 81 + let { identity } = useIdentityData(); 82 + let unreads = identity?.notifications[0]?.count; 83 + 84 + return ( 85 + <SpeedyLink href={"/notifications"} className="hover:no-underline!"> 86 + <ActionButton 87 + nav 88 + labelOnMobile={false} 89 + icon={ 90 + unreads ? ( 91 + <NotificationsUnreadSmall className="text-accent-contrast" /> 92 + ) : ( 93 + <NotificationsReadSmall /> 94 + ) 95 + } 96 + label="Notifications" 97 + className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`} 98 + /> 99 + </SpeedyLink> 100 + ); 101 + }
+61
components/ActionBar/ProfileButton.tsx
··· 1 + import { Avatar } from "components/Avatar"; 2 + import { ActionButton } from "./ActionButton"; 3 + import { useIdentityData } from "components/IdentityProvider"; 4 + import { AccountSmall } from "components/Icons/AccountSmall"; 5 + import { useRecordFromDid } from "src/utils/useRecordFromDid"; 6 + import { Menu, MenuItem } from "components/Menu"; 7 + import { useIsMobile } from "src/hooks/isMobile"; 8 + import { LogoutSmall } from "components/Icons/LogoutSmall"; 9 + import { mutate } from "swr"; 10 + import { SpeedyLink } from "components/SpeedyLink"; 11 + 12 + export const ProfileButton = () => { 13 + let { identity } = useIdentityData(); 14 + let { data: record } = useRecordFromDid(identity?.atp_did); 15 + let isMobile = useIsMobile(); 16 + 17 + return ( 18 + <Menu 19 + asChild 20 + side={isMobile ? "top" : "right"} 21 + align={isMobile ? "center" : "start"} 22 + trigger={ 23 + <ActionButton 24 + nav 25 + labelOnMobile={false} 26 + icon={ 27 + record ? ( 28 + <Avatar 29 + src={record.avatar} 30 + displayName={record.displayName || record.handle} 31 + /> 32 + ) : ( 33 + <AccountSmall /> 34 + ) 35 + } 36 + label={record ? record.displayName || record.handle : "Account"} 37 + className={`w-full`} 38 + /> 39 + } 40 + > 41 + {record && ( 42 + <> 43 + <SpeedyLink className="no-underline!" href={`/p/${record.handle}`}> 44 + <MenuItem onSelect={() => {}}>View Profile</MenuItem> 45 + </SpeedyLink> 46 + 47 + <hr className="border-border-light border-dashed" /> 48 + </> 49 + )} 50 + <MenuItem 51 + onSelect={async () => { 52 + await fetch("/api/auth/logout"); 53 + mutate("identity", null); 54 + }} 55 + > 56 + <LogoutSmall /> 57 + Log Out 58 + </MenuItem> 59 + </Menu> 60 + ); 61 + };
+148
components/ActionBar/PublicationNavigation.tsx
··· 1 + "use client"; 2 + import { useIdentityData } from "components/IdentityProvider"; 3 + import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { 5 + normalizePublicationRecord, 6 + type NormalizedPublication, 7 + } from "src/utils/normalizeRecords"; 8 + import { SpeedyLink } from "components/SpeedyLink"; 9 + import { Popover } from "components/Popover"; 10 + import { ButtonPrimary } from "components/Buttons"; 11 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 12 + import { HomeButton, type navPages } from "./NavigationButtons"; 13 + import { HomeSmall } from "components/Icons/HomeSmall"; 14 + import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 15 + import { PubIcon, PublicationButtons } from "./Publications"; 16 + import { HomeTiny } from "components/Icons/HomeTiny"; 17 + import { LooseleafTiny } from "components/Icons/LooseleafTiny"; 18 + import { Separator } from "components/Layout"; 19 + import { Menu, MenuItem } from "components/Menu"; 20 + import { AddTiny } from "components/Icons/AddTiny"; 21 + 22 + export const PublicationNavigation = (props: { 23 + currentPage: navPages; 24 + currentPubUri?: string; 25 + }) => { 26 + let { identity } = useIdentityData(); 27 + 28 + if (!identity) return; 29 + 30 + let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 31 + (f) => 32 + f.permission_tokens.leaflets_to_documents && 33 + f.permission_tokens.leaflets_to_documents[0]?.document, 34 + ); 35 + 36 + let pubCount = identity?.publications.length ?? 0; 37 + let onlyOnePub = pubCount === 1 && hasLooseleafs; 38 + let onlyLooseleafs = pubCount === 0 && hasLooseleafs; 39 + let className = 40 + "font-bold text-secondary flex gap-2 items-center grow min-w-0 text-sm h-[34px] px-2 accent-container"; 41 + 42 + // if not publications or looseleafs 43 + if (!identity.publications && !hasLooseleafs) { 44 + return ( 45 + <SpeedyLink href="/lish/createPub"> 46 + <ButtonPrimary compact className="text-sm!"> 47 + Create a Publication! 48 + </ButtonPrimary> 49 + </SpeedyLink> 50 + ); 51 + } 52 + 53 + switch (props.currentPage) { 54 + case "looseleafs": 55 + case "pub": 56 + if (onlyLooseleafs || onlyOnePub) 57 + return ( 58 + <> 59 + <SpeedyLink href={`/home`} className={className}> 60 + <HomeTiny className="shrink-0" /> 61 + Home 62 + </SpeedyLink> 63 + </> 64 + ); 65 + break; 66 + case "home": { 67 + if (onlyLooseleafs || onlyOnePub) { 68 + let pub = identity.publications[0]; 69 + return ( 70 + <div className={className}> 71 + <Menu trigger={<MoreOptionsVerticalTiny className="shrink-0" />}> 72 + <SpeedyLink href="/createPub"> 73 + <MenuItem className="items-center! text-sm" onSelect={() => {}}> 74 + <AddTiny /> 75 + Create New Publication 76 + </MenuItem> 77 + </SpeedyLink> 78 + </Menu> 79 + <Separator classname="h-6!" /> 80 + {onlyLooseleafs ? ( 81 + <SpeedyLink 82 + href="/looseleafs" 83 + className="hover:no-underline! text-inherit flex gap-2 items-center pr-2 w-full min-w-0" 84 + > 85 + <LooseleafTiny className="shrink-0" /> Looseleafs 86 + </SpeedyLink> 87 + ) : ( 88 + <SpeedyLink 89 + href={`${getBasePublicationURL(pub)}/dashboard`} 90 + className="hover:no-underline! text-inherit flex gap-2 items-center pr-2 w0ull min-w-0" 91 + > 92 + <PubIcon 93 + small 94 + record={normalizePublicationRecord(pub.record)} 95 + uri={pub.uri} 96 + /> 97 + <div className="truncate min-w-0">{pub.name}</div> 98 + </SpeedyLink> 99 + )} 100 + </div> 101 + ); 102 + } 103 + break; 104 + } 105 + } 106 + 107 + return ( 108 + <Popover 109 + trigger={ 110 + <div className={className}> 111 + <PubIcons 112 + publications={identity.publications.map((pub) => ({ 113 + record: normalizePublicationRecord(pub.record), 114 + uri: pub.uri, 115 + }))} 116 + />{" "} 117 + Publications 118 + </div> 119 + } 120 + className="pt-1 px-2!" 121 + > 122 + <HomeButton current={props.currentPage === "home"} /> 123 + <hr className="my-1 border-border-light" /> 124 + <PublicationButtons 125 + currentPage={props.currentPage} 126 + currentPubUri={props.currentPubUri} 127 + /> 128 + </Popover> 129 + ); 130 + }; 131 + 132 + function PubIcons(props: { 133 + publications: { record: NormalizedPublication | null; uri: string }[]; 134 + }) { 135 + if (props.publications.length < 1) return null; 136 + return ( 137 + <div className="flex"> 138 + {props.publications.map((pub, index) => { 139 + if (index <= 2) 140 + return ( 141 + <div className="-ml-[6px] first:ml-0" key={pub.uri}> 142 + <PubIcon small record={pub.record} uri={pub.uri} /> 143 + </div> 144 + ); 145 + })} 146 + </div> 147 + ); 148 + }
+18 -11
components/ActionBar/Publications.tsx
··· 19 19 import { useIsMobile } from "src/hooks/isMobile"; 20 20 import { useState } from "react"; 21 21 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 - import { navPages } from "./Navigation"; 22 + import { type navPages } from "./NavigationButtons"; 23 23 24 24 export const PublicationButtons = (props: { 25 25 currentPage: navPages; 26 26 currentPubUri: string | undefined; 27 + className?: string; 28 + optionClassName?: string; 27 29 }) => { 28 30 let { identity } = useIdentityData(); 29 31 let hasLooseleafs = !!identity?.permission_token_on_homepage.find( ··· 38 40 return <PubListEmpty />; 39 41 40 42 return ( 41 - <div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0"> 43 + <div 44 + className={`pubListWrapper w-full flex flex-col sm:bg-transparent sm:border-0 ${props.className}`} 45 + > 42 46 {hasLooseleafs && ( 43 47 <> 44 48 <SpeedyLink 45 49 href={`/looseleafs`} 46 - className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 50 + className={`flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full `} 47 51 > 48 52 {/*TODO How should i get if this is the current page or not? 49 53 theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/} ··· 51 55 label="Looseleafs" 52 56 icon={<LooseLeafSmall />} 53 57 nav 54 - className={ 58 + className={`w-full! ${ 55 59 props.currentPage === "looseleafs" 56 60 ? "bg-bg-page! border-border!" 57 61 : "" 58 62 } 63 + ${props.optionClassName}`} 59 64 /> 60 65 </SpeedyLink> 61 - <hr className="border-border-light border-dashed mx-1" /> 62 66 </> 63 67 )} 64 68 ··· 86 90 })} 87 91 <Link 88 92 href={"/lish/createPub"} 89 - className="pubListCreateNew text-accent-contrast text-sm place-self-end hover:text-accent-contrast" 93 + className={`pubListCreateNew group/new-pub text-tertiary hover:text-accent-contrast flex gap-2 items-center p-1 no-underline! ${props.optionClassName}`} 90 94 > 91 - New 95 + <div className="group-hover/new-pub:border-accent-contrast w-6 h-6 border-border-light border-2 border-dashed rounded-full" /> 96 + New Publication 92 97 </Link> 93 98 </div> 94 99 ); ··· 99 104 name: string; 100 105 record: Json; 101 106 current?: boolean; 107 + className?: string; 102 108 }) => { 103 109 let record = normalizePublicationRecord(props.record); 104 110 if (!record) return; ··· 106 112 return ( 107 113 <SpeedyLink 108 114 href={`${getBasePublicationURL(props)}/dashboard`} 109 - className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 115 + className={`flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full `} 110 116 > 111 117 <ActionButton 112 118 label={record.name} 113 119 icon={<PubIcon record={record} uri={props.uri} />} 114 120 nav 115 - className={props.current ? "bg-bg-page! border-border!" : ""} 121 + className={`w-full! ${props.current ? "bg-bg-page! border-border!" : ""} ${props.className}`} 116 122 /> 117 123 </SpeedyLink> 118 124 ); ··· 198 204 export const PubIcon = (props: { 199 205 record: NormalizedPublication | null; 200 206 uri: string; 207 + tiny?: boolean; 201 208 small?: boolean; 202 209 large?: boolean; 203 210 className?: string; 204 211 }) => { 205 212 if (!props.record) return null; 206 213 207 - let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 214 + let iconSizeClassName = `${props.tiny ? "w-4 h-4" : props.small ? "w-5 h-5" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 208 215 209 216 return props.record.icon ? ( 210 217 <div ··· 221 228 ) : ( 222 229 <div className={`${iconSizeClassName} bg-accent-1 relative`}> 223 230 <div 224 - className={`${props.small ? "text-xs" : props.large ? "text-2xl" : "text-sm"} font-bold absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2`} 231 + className={`${props.tiny ? "text-xs" : props.large ? "text-2xl" : "text-sm"} font-bold absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2`} 225 232 > 226 233 {props.record?.name.slice(0, 1).toUpperCase()} 227 234 </div>
-1
components/Blocks/BaseTextareaBlock.tsx
··· 83 83 let block = props.block.nextBlock; 84 84 85 85 let coord = getCoordinatesInTextarea(e.currentTarget, selection); 86 - console.log(coord); 87 86 if (block) { 88 87 focusBlock(block, { 89 88 left: coord.left + e.currentTarget.getBoundingClientRect().left,
+1
components/Blocks/Block.tsx
··· 419 419 if (focusedEntity?.entityType === "page") return; 420 420 421 421 if (isMultiselected) return; 422 + if (!entity_set.permissions.write) return null; 422 423 423 424 return ( 424 425 <div
+4 -33
components/Blocks/BlueskyPostBlock/index.tsx
··· 1 1 import { useEntitySetContext } from "components/EntitySetProvider"; 2 - import { useEffect, useState } from "react"; 2 + import { useEffect } from "react"; 3 3 import { useEntity } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 5 import { BlockProps, BlockLayout } from "../Block"; 6 6 import { elementId } from "src/utils/elementId"; 7 7 import { focusBlock } from "src/utils/focusBlock"; 8 - import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 8 + import { AppBskyFeedDefs } from "@atproto/api"; 9 9 import { PostNotAvailable } from "./BlueskyEmbed"; 10 10 import { BlueskyPostEmpty } from "./BlueskyEmpty"; 11 11 12 - import { Separator } from "components/Layout"; 13 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 - import { CommentTiny } from "components/Icons/CommentTiny"; 15 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 12 import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; 17 13 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 18 14 ··· 22 18 s.selectedBlocks.find((b) => b.value === props.entityID), 23 19 ); 24 20 let post = useEntity(props.entityID, "block/bluesky-post")?.data.value; 21 + let clientHost = useEntity(props.entityID, "bluesky-post/host")?.data.value; 25 22 26 23 useEffect(() => { 27 24 if (props.preview) return; ··· 64 61 ); 65 62 66 63 case AppBskyFeedDefs.isThreadViewPost(post): 67 - let record = post.post 68 - .record as AppBskyFeedDefs.FeedViewPost["post"]["record"]; 69 - let facets = record.facets; 70 - 71 - // silliness to get the text and timestamp from the record with proper types 72 - let text: string | null = null; 73 - let timestamp: string | undefined = undefined; 74 - if (AppBskyFeedPost.isRecord(record)) { 75 - text = (record as AppBskyFeedPost.Record).text; 76 - timestamp = (record as AppBskyFeedPost.Record).createdAt; 77 - } 78 - 79 - //getting the url to the post 80 - let postId = post.post.uri.split("/")[4]; 81 64 let postView = post.post as PostView; 82 - let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 83 65 84 66 return ( 85 67 <BlockLayout ··· 95 77 showEmbed={true} 96 78 avatarSize="large" 97 79 className="text-sm text-secondary " 80 + clientHost={clientHost} 98 81 /> 99 82 </BlockLayout> 100 83 ); 101 84 } 102 85 }; 103 - 104 - function PostDate(props: { timestamp: string }) { 105 - const formattedDate = useLocalizedDate(props.timestamp, { 106 - month: "short", 107 - day: "numeric", 108 - year: "numeric", 109 - hour: "numeric", 110 - minute: "numeric", 111 - hour12: true, 112 - }); 113 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 114 - }
+6 -3
components/Blocks/TextBlock/index.tsx
··· 318 318 ); 319 319 } 320 320 321 + const blueskyclients = ["blacksky.community/", "bsky.app/", "witchsky.app/"]; 322 + 321 323 const BlockifyLink = (props: { 322 324 entityID: string; 323 325 editorState: EditorState | undefined; ··· 329 331 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 330 332 331 333 let isBlueskyPost = 332 - editorState?.doc.textContent.includes("bsky.app/") && 333 - editorState?.doc.textContent.includes("post"); 334 - // only if the line stats with http or https and doesn't have other content 334 + blueskyclients.some((client) => 335 + editorState?.doc.textContent.includes(client), 336 + ) && editorState?.doc.textContent.includes("post"); 337 + // only if the line starts with http or https and doesn't have other content 335 338 // if its bluesky, change text to embed post 336 339 337 340 if (
+19 -23
components/Blocks/TextBlock/mountProsemirror.ts
··· 80 80 handlePaste, 81 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 82 82 if (!direct) return; 83 + 84 + // Check for didMention inline nodes 85 + if (node?.type === schema.nodes.didMention) { 86 + window.open( 87 + didToBlueskyUrl(node.attrs.did), 88 + "_blank", 89 + "noopener,noreferrer", 90 + ); 91 + return; 92 + } 93 + 94 + // Check for atMention inline nodes 95 + if (node?.type === schema.nodes.atMention) { 96 + const url = atUriToUrl(node.attrs.atURI); 97 + window.open(url, "_blank", "noopener,noreferrer"); 98 + return; 99 + } 83 100 if (node.nodeSize - 2 <= _pos) return; 84 101 85 102 // Check for marks at the clicked position ··· 87 104 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 88 105 89 106 // Check for link marks 90 - let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 107 + let linkMark = 108 + nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 91 109 nodeAt2?.marks.find((f) => f.type === schema.marks.link); 92 110 if (linkMark) { 93 111 window.open(linkMark.attrs.href, "_blank"); 94 - return; 95 - } 96 - 97 - // Check for didMention inline nodes 98 - if (nodeAt1?.type === schema.nodes.didMention) { 99 - window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); 100 - return; 101 - } 102 - if (nodeAt2?.type === schema.nodes.didMention) { 103 - window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); 104 - return; 105 - } 106 - 107 - // Check for atMention inline nodes 108 - if (nodeAt1?.type === schema.nodes.atMention) { 109 - const url = atUriToUrl(nodeAt1.attrs.atURI); 110 - window.open(url, "_blank", "noopener,noreferrer"); 111 - return; 112 - } 113 - if (nodeAt2?.type === schema.nodes.atMention) { 114 - const url = atUriToUrl(nodeAt2.attrs.atURI); 115 - window.open(url, "_blank", "noopener,noreferrer"); 116 112 return; 117 113 } 118 114 },
+20 -1
components/Blocks/TextBlock/useHandlePaste.ts
··· 396 396 ]); 397 397 } 398 398 399 + if (child.tagName === "DIV" && child.getAttribute("data-bluesky-post")) { 400 + let postData = child.getAttribute("data-bluesky-post"); 401 + if (postData) { 402 + rep.mutate.assertFact([ 403 + { 404 + entity: entityID, 405 + attribute: "block/type", 406 + data: { type: "block-type-union", value: "bluesky-post" }, 407 + }, 408 + { 409 + entity: entityID, 410 + attribute: "block/bluesky-post", 411 + data: { type: "bluesky-post", value: JSON.parse(postData) }, 412 + }, 413 + ]); 414 + } 415 + } 416 + 399 417 if (child.tagName === "DIV" && child.getAttribute("data-entityid")) { 400 418 let oldEntityID = child.getAttribute("data-entityid") as string; 401 419 let factsData = child.getAttribute("data-facts"); ··· 593 611 "HR", 594 612 ].includes(elementNode.tagName) || 595 613 elementNode.getAttribute("data-entityid") || 596 - elementNode.getAttribute("data-tex") 614 + elementNode.getAttribute("data-tex") || 615 + elementNode.getAttribute("data-bluesky-post") 597 616 ) { 598 617 htmlBlocks.push(elementNode); 599 618 } else {
+33 -5
components/Canvas.tsx
··· 19 19 import { Separator } from "./Layout"; 20 20 import { CommentTiny } from "./Icons/CommentTiny"; 21 21 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 - import { PublicationMetadata } from "./Pages/PublicationMetadata"; 22 + import { AddTags, PublicationMetadata } from "./Pages/PublicationMetadata"; 23 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 25 import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 26 + import { RecommendTinyEmpty } from "./Icons/RecommendTiny"; 27 + import { useSubscribe } from "src/replicache/useSubscribe"; 28 + import { mergePreferences } from "src/utils/mergePreferences"; 26 29 27 30 export function Canvas(props: { 28 31 entityID: string; ··· 163 166 164 167 const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 165 168 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 169 + let { rep } = useReplicache(); 170 + let postPreferences = useSubscribe(rep, (tx) => 171 + tx.get<{ 172 + showComments?: boolean; 173 + showMentions?: boolean; 174 + showRecommends?: boolean; 175 + } | null>("post_preferences"), 176 + ); 166 177 if (!pub || !pub.publications) return null; 167 178 168 179 if (!normalizedPublication) return null; 169 - let showComments = normalizedPublication.preferences?.showComments !== false; 170 - let showMentions = normalizedPublication.preferences?.showMentions !== false; 180 + let merged = mergePreferences( 181 + postPreferences || undefined, 182 + normalizedPublication.preferences, 183 + ); 184 + let showComments = merged.showComments !== false; 185 + let showMentions = merged.showMentions !== false; 186 + let showRecommends = merged.showRecommends !== false; 171 187 172 188 return ( 173 189 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 190 + {showRecommends && ( 191 + <div className="flex gap-1 text-tertiary items-center"> 192 + <RecommendTinyEmpty className="text-border" /> — 193 + </div> 194 + )} 174 195 {showComments && ( 175 196 <div className="flex gap-1 text-tertiary items-center"> 176 197 <CommentTiny className="text-border" /> — 177 198 </div> 178 199 )} 179 - {showComments && ( 200 + {showMentions && ( 180 201 <div className="flex gap-1 text-tertiary items-center"> 181 202 <QuoteTiny className="text-border" /> — 182 203 </div> 183 204 )} 184 205 206 + {showMentions !== false || 207 + showComments !== false || 208 + showRecommends === false ? ( 209 + <Separator classname="h-4!" /> 210 + ) : null} 211 + <AddTags /> 212 + 185 213 {!props.isSubpage && ( 186 214 <> 187 215 <Separator classname="h-5" /> ··· 191 219 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 192 220 trigger={<InfoSmall />} 193 221 > 194 - <PublicationMetadata /> 222 + <PublicationMetadata noInteractions /> 195 223 </Popover> 196 224 </> 197 225 )}
+21
components/Icons/HomeTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const HomeTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M3.5906 0.287577C3.39798 0.211458 3.18013 0.305893 3.10402 0.498504C3.0706 0.583064 3.07005 0.672489 3.09625 0.75265C3.09112 0.756892 3.08729 0.759504 3.08536 0.760636C3.01766 0.800372 2.93363 0.858436 2.8665 0.940226C2.79683 1.02511 2.71837 1.17222 2.75663 1.35689C2.7922 1.52854 2.91018 1.63661 3.00046 1.69802C3.09505 1.76237 3.2113 1.81163 3.33752 1.85239C3.39934 1.87236 3.45003 1.8919 3.49051 1.91036C3.48033 1.91756 3.46441 1.92788 3.43885 1.94314L3.42397 1.95192C3.38779 1.97318 3.32461 2.0103 3.27192 2.05173C3.20599 2.10358 3.10812 2.19751 3.06658 2.34833C3.0237 2.50404 3.05981 2.64893 3.11488 2.76333C3.2047 2.94994 3.4288 3.02841 3.61541 2.93858C3.75466 2.87155 3.83369 2.72977 3.82752 2.58459C3.87484 2.55627 3.94958 2.5103 4.01834 2.44579C4.12918 2.3418 4.23095 2.21532 4.28001 2.06267C4.33473 1.89238 4.3137 1.71934 4.2251 1.57134C4.14607 1.43931 4.0277 1.35059 3.92325 1.29025C3.85742 1.25222 3.78599 1.21914 3.71221 1.19005C3.76063 1.12961 3.80094 1.06476 3.83053 1.00004C3.86565 0.923217 3.90498 0.804913 3.88814 0.670895C3.86764 0.507679 3.76561 0.356744 3.5906 0.287577ZM3.14984 0.684673C3.14991 0.684654 3.14956 0.685711 3.14857 0.687897C3.14928 0.685784 3.14977 0.684691 3.14984 0.684673ZM3.41985 1.07611L3.4216 1.07742C3.42036 1.07656 3.41981 1.07612 3.41985 1.07611ZM11.0044 2.01981C10.8258 1.79122 10.5134 1.71555 10.25 1.83709L5.05737 4.23314V3.71118C5.05737 3.43504 4.83352 3.21118 4.55737 3.21118H2.49927C2.22313 3.21118 1.99927 3.43504 1.99927 3.71118V6.72759L0.587556 7.98327C0.329642 8.21268 0.306533 8.60773 0.535942 8.86564C0.711092 9.06256 0.982798 9.1226 1.21631 9.03789V12.7554C1.21631 12.9616 1.34294 13.1467 1.53518 13.2214L3.35104 13.9272C3.50487 13.9869 3.6783 13.967 3.81451 13.8738C3.82806 13.8645 3.84106 13.8546 3.8535 13.8442L5.42986 12.9319V14.2399C5.42986 14.4459 5.55627 14.6309 5.74826 14.7057L8.04946 15.6028C8.1866 15.6563 8.34034 15.6466 8.46969 15.5763L14.5223 12.2892C14.6834 12.2017 14.7837 12.0331 14.7837 11.8498V8.93436L15.2609 8.71207C15.4336 8.63163 15.5602 8.4768 15.6047 8.29156C15.6492 8.10632 15.6068 7.91088 15.4895 7.76075L11.0044 2.01981ZM5.42026 11.7821C5.39313 11.546 5.30858 11.2485 5.16635 10.9817C4.98526 10.6419 4.77171 10.4608 4.58539 10.4181C4.47274 10.3923 4.36021 10.4233 4.2466 10.5542C4.1247 10.6947 4.03217 10.9273 4.03217 11.1918V12.5854L5.42026 11.7821ZM4.05737 4.87826C4.05737 4.88442 4.05748 4.89055 4.05771 4.89665L2.99927 5.83811V4.21118H4.05737V4.87826ZM13.7837 9.40019L9.23842 11.5175C8.96198 11.6463 8.63296 11.5568 8.4599 11.3057L4.76308 5.94217L2.21631 8.20746V12.4133L3.03217 12.7304V11.1918C3.03217 10.7137 3.19458 10.2407 3.49138 9.89876C3.79647 9.54724 4.26639 9.31907 4.80875 9.44335C5.40674 9.58038 5.80959 10.0625 6.04881 10.5113C6.29331 10.97 6.42986 11.507 6.42986 11.938V13.8981L7.76685 14.4193V12.47C7.76685 12.1938 7.9907 11.97 8.26685 11.97C8.54299 11.97 8.76685 12.1938 8.76685 12.47V14.277L13.7837 11.5523V9.40019ZM9.18936 10.1614L5.81211 5.26154L10.3242 3.17952L14.0205 7.91089L9.18936 10.1614Z" 17 + fill="currentColor" 18 + /> 19 + </svg> 20 + ); 21 + };
+19
components/Icons/LooseleafTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const LooseleafTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M11.2738 3.16162L15.0267 3.56201C15.3663 3.5985 15.6139 3.90154 15.5824 4.2417C15.4811 5.33541 15.3687 6.86768 15.1898 8.27196C15.1 8.97649 14.9914 9.66447 14.8558 10.2641C14.7231 10.8509 14.5533 11.4049 14.3197 11.81C13.8598 12.6071 13.0155 13.1869 12.3167 13.5483C11.9571 13.7343 11.6082 13.8768 11.3206 13.9702C11.1775 14.0167 11.0401 14.0541 10.9193 14.0776C10.7541 14.1097 10.5913 14.1051 10.4251 14.0962C10.1231 14.0799 9.42901 14.0296 8.58334 13.8823C7.47126 13.6886 6.03035 13.3156 4.90268 12.5825C4.57534 12.3697 4.21248 12.0972 3.84506 11.8022C3.46225 12.2159 3.05921 12.5047 2.64194 12.6431C2.0308 12.8455 1.43066 12.7101 1.00424 12.2759C0.608272 11.8724 0.427321 11.2804 0.415374 10.6606C0.403254 10.0297 0.562907 9.31159 0.915374 8.57372C1.00241 8.39185 1.17191 8.26311 1.37045 8.22802C1.77258 8.15726 2.03737 8.51678 2.2933 8.76024C2.5295 8.98492 2.85946 9.29431 3.23373 9.63231C3.99203 10.3171 4.90157 11.0908 5.58432 11.5347C6.49837 12.1288 7.73879 12.4665 8.79721 12.6509C9.57737 12.7867 10.2253 12.8337 10.4925 12.8481C10.6571 12.857 10.7671 12.8352 10.9349 12.7807C11.155 12.7093 11.441 12.5939 11.7425 12.438C12.3673 12.1148 12.9557 11.6727 13.2367 11.186C13.3732 10.9493 13.511 10.5452 13.6371 9.98778C13.7602 9.44308 13.8622 8.79923 13.9496 8.11376C14.0946 6.97515 14.1935 5.76335 14.2826 4.73974L11.141 4.40381C10.7981 4.36676 10.5497 4.05925 10.5863 3.71631C10.623 3.37337 10.9309 3.12535 11.2738 3.16162ZM1.74155 9.95751C1.68375 10.2082 1.66055 10.4373 1.6644 10.6372C1.67254 11.0552 1.7951 11.2962 1.89682 11.3999C1.96817 11.4725 2.06103 11.5185 2.24838 11.4565C2.40601 11.4043 2.62567 11.2693 2.88803 10.9956C2.71779 10.8466 2.55167 10.7008 2.39584 10.56C2.1555 10.343 1.93347 10.1371 1.74155 9.95751ZM7.96322 6.41162C8.31566 6.24529 8.70512 6.24366 9.04233 6.44872C9.34367 6.63216 9.52187 6.93155 9.62338 7.20556C9.72836 7.48916 9.77666 7.81215 9.7767 8.1372C9.77667 8.6247 9.74792 9.11115 9.78354 9.59813C9.92459 9.40912 10.1034 9.15794 10.2708 8.95556C10.4074 8.79053 10.6272 8.54235 10.9017 8.42333C11.0496 8.37061 11.1951 8.34487 11.3519 8.38329C11.5136 8.42299 11.628 8.51182 11.6996 8.58642C11.8258 8.71812 11.8852 8.87607 11.9124 8.95458C11.9724 9.12791 12.0164 9.36363 12.0472 9.51122C12.0685 9.61332 12.0877 9.69219 12.1038 9.75145C12.2564 9.83216 12.3762 9.97288 12.4203 10.1538C12.5016 10.489 12.2964 10.827 11.9613 10.9087C11.7048 10.971 11.4618 10.9006 11.2826 10.7563C11.133 10.6358 11.0491 10.4826 11.0033 10.3823C10.9715 10.3127 10.9455 10.2369 10.9222 10.1616C10.8894 10.2062 10.8558 10.2528 10.8206 10.3003C10.6776 10.4935 10.5049 10.7208 10.3285 10.8921C10.2401 10.9778 10.1241 11.0763 9.98471 11.1479C9.84326 11.2205 9.62468 11.2914 9.37338 11.229C9.12739 11.1678 8.96103 11.0117 8.85873 10.8696C8.75978 10.7319 8.6993 10.5791 8.65951 10.4477C8.57995 10.1849 8.54518 9.87525 8.52865 9.5952C8.49614 9.04324 8.52667 8.38518 8.5267 8.1372C8.52667 7.95206 8.50644 7.75603 8.42709 7.58642C8.35866 7.63907 8.25391 7.74085 8.12045 7.92724C7.96238 8.14867 7.88446 8.42797 7.77572 8.67626C7.60415 9.06796 7.39016 9.49817 6.98471 9.69188C6.67331 9.84037 6.29944 9.70824 6.15072 9.39696C6.00888 9.09909 6.12486 8.74671 6.40756 8.58642C6.51366 8.47888 6.57211 8.31017 6.63119 8.17528C6.77553 7.84573 6.89305 7.49444 7.10385 7.1997C7.35704 6.84609 7.64105 6.56381 7.96322 6.41162ZM5.92026 2.61377C6.26261 2.65786 6.50438 2.97161 6.4603 3.31396C6.34872 4.18082 6.36724 5.06034 6.26889 5.92919C6.15284 6.95425 5.90409 8.0946 5.25912 9.16552C5.08097 9.46107 4.69635 9.55648 4.40073 9.37841C4.10557 9.20023 4.0101 8.81645 4.18783 8.52099C4.703 7.66575 4.92072 6.72471 5.0267 5.78857C5.1258 4.91308 5.10851 4.02764 5.22104 3.15381C5.26521 2.81184 5.57829 2.57015 5.92026 2.61377ZM8.44369 1.89307C9.32915 1.89314 10.0472 2.61111 10.0472 3.49658C10.0472 3.77125 9.97757 4.02935 9.8558 4.25537L10.2923 4.7417C10.5227 4.99871 10.5005 5.39412 10.2435 5.62451C9.98648 5.85467 9.59102 5.83358 9.36069 5.57666L8.87826 5.03857C8.74 5.07745 8.59436 5.10008 8.44369 5.10009C7.55837 5.09991 6.84025 4.38192 6.84018 3.49658C6.84018 2.61117 7.55833 1.89325 8.44369 1.89307ZM8.44369 3.14306C8.24868 3.14325 8.09018 3.30153 8.09018 3.49658C8.09025 3.69157 8.24873 3.84991 8.44369 3.8501C8.63875 3.85002 8.79713 3.69163 8.79721 3.49658C8.79721 3.30146 8.63879 3.14314 8.44369 3.14306Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1
components/Icons/MoreOptionsVerticalTiny.tsx
··· 8 8 viewBox="0 0 12 24" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 d="M6 15.5C6.82843 15.5 7.5 16.1716 7.5 17C7.5 17.8284 6.82843 18.5 6 18.5C5.17157 18.5 4.5 17.8284 4.5 17C4.5 16.1716 5.17157 15.5 6 15.5ZM6 10.5C6.82843 10.5 7.5 11.1716 7.5 12C7.5 12.8284 6.82843 13.5 6 13.5C5.17157 13.5 4.5 12.8284 4.5 12C4.5 11.1716 5.17157 10.5 6 10.5ZM6 5.5C6.82843 5.5 7.5 6.17157 7.5 7C7.5 7.82843 6.82843 8.5 6 8.5C5.17157 8.5 4.5 7.82843 4.5 7C4.5 6.17157 5.17157 5.5 6 5.5Z"
+1 -36
components/Icons/NotificationSmall.tsx
··· 26 26 viewBox="0 0 24 24" 27 27 fill="none" 28 28 xmlns="http://www.w3.org/2000/svg" 29 - > 30 - <path 31 - d="M12.3779 0.890636C13.5297 0.868361 14.2312 1.35069 14.6104 1.8047C15.1942 2.50387 15.2636 3.34086 15.2129 3.95314C17.7074 4.96061 18.8531 7.45818 19.375 10.3975C19.5903 11.1929 20.0262 11.5635 20.585 11.9336C21.1502 12.3079 22.0847 12.7839 22.5879 13.7998C23.4577 15.556 22.8886 17.8555 20.9297 19.083C20.1439 19.5754 19.2029 20.1471 17.8496 20.5869C17.1962 20.7993 16.454 20.9768 15.5928 21.1055C15.2068 22.4811 13.9287 23.4821 12.4238 23.4824C10.9225 23.4824 9.64464 22.4867 9.25489 21.1162C8.37384 20.9871 7.61998 20.8046 6.95899 20.5869C5.62158 20.1464 4.69688 19.5723 3.91602 19.083C1.95717 17.8555 1.38802 15.556 2.25782 13.7998C2.76329 12.7794 3.60199 12.3493 4.18653 12.0068C4.7551 11.6737 5.1753 11.386 5.45606 10.7432C5.62517 9.31217 5.93987 8.01645 6.4668 6.92482C7.1312 5.54855 8.13407 4.49633 9.56251 3.92482C9.53157 3.34709 9.6391 2.63284 10.1133 1.98927C10.1972 1.87543 10.4043 1.594 10.7822 1.34669C11.1653 1.09611 11.6872 0.904101 12.3779 0.890636ZM14.1709 21.2608C13.6203 21.3007 13.0279 21.3242 12.3887 21.3242C11.7757 21.3242 11.2072 21.3024 10.6777 21.2656C11.0335 21.8421 11.6776 22.2324 12.4238 22.2324C13.1718 22.2321 13.816 21.8396 14.1709 21.2608ZM12.4004 2.38966C11.9872 2.39776 11.7419 2.50852 11.5996 2.60157C11.4528 2.6977 11.3746 2.801 11.3193 2.87599C11.088 3.19 11.031 3.56921 11.0664 3.92677C11.084 4.10311 11.1233 4.258 11.1631 4.37013C11.1875 4.43883 11.205 4.47361 11.21 4.48341C11.452 4.78119 11.4299 5.22068 11.1484 5.49415C10.8507 5.78325 10.3748 5.77716 10.0869 5.48048C10.0533 5.44582 10.0231 5.40711 9.99415 5.3672C9.0215 5.79157 8.31886 6.53162 7.81641 7.57228C7.21929 8.80941 6.91013 10.4656 6.82129 12.4746L6.81934 12.5137L6.81446 12.5518C6.73876 13.0607 6.67109 13.5103 6.53418 13.9121C6.38567 14.3476 6.16406 14.7061 5.82032 15.0899C5.54351 15.3988 5.06973 15.4268 4.76172 15.1514C4.45392 14.8758 4.42871 14.4019 4.70508 14.0928C4.93763 13.8332 5.04272 13.6453 5.11524 13.4326C5.14365 13.3492 5.16552 13.2588 5.18848 13.1553C5.10586 13.2062 5.02441 13.2544 4.94532 13.3008C4.28651 13.6868 3.87545 13.9129 3.60157 14.4658C3.08548 15.5082 3.38433 16.9793 4.71192 17.8115C5.4776 18.2913 6.27423 18.7818 7.42872 19.1621C8.58507 19.543 10.1358 19.8242 12.3887 19.8242C14.6416 19.8242 16.2108 19.5429 17.3857 19.1611C18.5582 18.7801 19.3721 18.2882 20.1328 17.8115C21.4611 16.9793 21.7595 15.5084 21.2432 14.4658C20.9668 13.9081 20.515 13.6867 19.7568 13.1846C19.7553 13.1835 19.7535 13.1827 19.752 13.1817C19.799 13.3591 19.8588 13.5202 19.9287 13.6514C20.021 13.8244 20.1034 13.8927 20.1533 13.917C20.5249 14.0981 20.6783 14.5465 20.4961 14.919C20.3135 15.2913 19.8639 15.4467 19.4922 15.2656C19.0607 15.0553 18.7821 14.6963 18.6035 14.3613C18.4238 14.0242 18.3154 13.6559 18.2471 13.3379C18.1778 13.0155 18.1437 12.7147 18.127 12.4971C18.1185 12.3873 18.1145 12.2956 18.1123 12.2305C18.1115 12.2065 18.1107 12.1856 18.1104 12.169C18.0569 11.6585 17.9885 11.1724 17.9082 10.7109C17.9002 10.6794 17.8913 10.6476 17.8838 10.6152L17.8906 10.6133C17.4166 7.97573 16.4732 6.17239 14.791 5.40821C14.5832 5.64607 14.2423 5.73912 13.9365 5.61036C13.5557 5.44988 13.3777 5.01056 13.5391 4.62892C13.5394 4.62821 13.5397 4.62699 13.54 4.62599C13.5425 4.61977 13.5479 4.6087 13.5537 4.59278C13.5658 4.55999 13.5837 4.50758 13.6035 4.44142C13.6438 4.30713 13.6903 4.12034 13.7139 3.91212C13.7631 3.47644 13.7038 3.06402 13.457 2.76857C13.3434 2.63264 13.0616 2.37678 12.4004 2.38966ZM10.1055 16.625C11.6872 16.8411 12.8931 16.8585 13.8174 16.7539C14.2287 16.7076 14.5997 17.0028 14.6465 17.4141C14.693 17.8256 14.3969 18.1976 13.9854 18.2442C12.9038 18.3665 11.5684 18.3389 9.90235 18.1113C9.49223 18.0551 9.20488 17.6768 9.26075 17.2666C9.3168 16.8563 9.6952 16.5691 10.1055 16.625ZM16.3887 16.3047C16.7403 16.086 17.203 16.1935 17.4219 16.5449C17.6406 16.8967 17.5324 17.3594 17.1807 17.5781C16.9689 17.7097 16.6577 17.8424 16.4033 17.9131C16.0045 18.0237 15.5914 17.7904 15.4805 17.3916C15.3696 16.9926 15.6031 16.5788 16.002 16.4678C16.1344 16.431 16.3112 16.3527 16.3887 16.3047Z" 32 - fill="currentColor" 33 - /> 34 - </svg> 35 - ); 36 - }; 37 - 38 - export const ReaderUnread = (props: Props) => { 39 - return ( 40 - <svg 41 - width="24" 42 - height="24" 43 - viewBox="0 0 24 24" 44 - fill="none" 45 - xmlns="http://www.w3.org/2000/svg" 46 29 {...props} 47 30 > 48 31 <path 49 - d="M2.40963 18.0472C2.40983 17.3641 2.99636 17.3642 2.99655 18.0472C2.99654 18.7307 3.28805 19.1528 3.38815 19.263C3.48823 19.3732 3.66105 19.7179 4.28463 19.7181C4.90847 19.7182 4.90847 20.3255 4.28463 20.3255C3.66107 20.3256 3.56221 20.5899 3.38815 20.7816C3.21412 20.9732 2.99855 21.2572 2.9985 21.9232C2.9985 22.5896 2.41159 22.6066 2.41159 21.9232C2.41151 21.2398 2.04325 20.8276 2.00143 20.7816C1.959 20.735 1.52689 20.3255 1.03854 20.3255C0.549966 20.3254 0.554718 19.7182 1.03854 19.7181C1.52186 19.7181 1.83806 19.3644 1.93014 19.263C2.02172 19.1622 2.40963 18.7307 2.40963 18.0472ZM18.3989 13.7962C18.3991 13.1543 18.8958 13.1543 18.896 13.7962C18.896 14.4386 19.1424 14.8352 19.227 14.9388C19.3117 15.0425 19.4577 15.3663 19.9848 15.3665C20.5125 15.3666 20.5125 15.9378 19.9848 15.9378C19.4575 15.9379 19.3742 16.1864 19.227 16.3665C19.0798 16.5466 18.897 16.8131 18.8969 17.4388C18.8969 18.0651 18.4008 18.0811 18.4008 17.4388C18.4007 16.7986 18.0911 16.4117 18.0542 16.3665C18.0188 16.3233 17.653 15.9381 17.2397 15.9378C16.8263 15.9378 16.8303 15.3665 17.2397 15.3665C17.6486 15.3663 17.916 15.0338 17.9936 14.9388C18.0711 14.844 18.3989 14.4386 18.3989 13.7962ZM5.68893 1.79134C6.32061 1.54967 6.8992 1.76772 7.31002 2.04427C7.71467 2.31686 8.06882 2.71494 8.35299 3.07649C8.63964 3.44125 8.90666 3.83827 9.10299 4.11653C9.3417 4.45493 9.26161 4.92361 8.9233 5.16243C8.58506 5.40111 8.1173 5.31974 7.87838 4.98177C7.65546 4.66583 7.42734 4.32673 7.17233 4.00227C6.91507 3.67506 6.67898 3.42822 6.47311 3.28938C6.3745 3.22296 6.30685 3.19837 6.27096 3.19075C6.24474 3.18528 6.23525 3.18881 6.22506 3.1927C6.18423 3.20859 5.9833 3.32114 5.7192 3.88899C5.59862 4.14843 5.29699 5.01402 4.93209 6.10677C4.85847 6.12987 4.80511 6.14661 4.79831 6.15462C4.79485 6.15884 4.79121 6.16214 4.78854 6.16536C4.4966 6.19308 4.21288 6.31734 3.99459 6.54427C3.75682 6.79162 3.64898 7.10768 3.64791 7.40462C3.64696 7.70171 3.75272 8.02065 3.99264 8.27083C4.04882 8.32937 4.1096 8.38044 4.1733 8.42513C3.95587 9.09776 3.75091 9.74532 3.5776 10.2884C3.85066 10.3144 4.12174 10.3779 4.38034 10.4691C5.16136 10.7446 5.92693 11.3005 6.54928 12.0501C6.71967 11.9646 6.90306 11.9158 7.08932 11.8929C7.52605 11.8392 7.97349 11.9251 8.39889 12.1146C8.52073 11.5922 8.77238 11.1032 9.17428 10.6868C10.661 9.14683 13.2111 9.62241 14.8227 11.1781C14.9553 11.306 15.0799 11.4413 15.1977 11.5814C15.3522 11.0931 15.5412 10.4983 15.7387 9.87044C16.2338 8.29641 16.7876 6.52428 17.0298 5.71028C17.333 4.69126 17.7647 3.91964 18.3823 3.49056C19.0569 3.02218 19.7993 3.06179 20.4389 3.34017C21.047 3.60494 21.6053 4.09649 22.0825 4.63802C22.5684 5.18942 23.0142 5.84393 23.3862 6.50032C23.59 6.86052 23.4639 7.31757 23.104 7.52181C22.7438 7.72592 22.2859 7.59954 22.0815 7.23958C21.7486 6.6521 21.3605 6.08653 20.9575 5.62923C20.546 5.16239 20.1597 4.85528 19.8403 4.71614C19.5524 4.5908 19.3833 4.62119 19.2368 4.72298C19.0332 4.86476 18.7324 5.24367 18.4663 6.13802C18.2201 6.9656 17.6633 8.74963 17.1694 10.3197C16.922 11.1061 16.6893 11.8418 16.519 12.3802C16.4339 12.6494 16.3652 12.87 16.3169 13.0228C16.2928 13.0988 16.274 13.1581 16.2612 13.1986C16.2548 13.2188 16.2489 13.235 16.2456 13.2454C16.2441 13.2501 16.2425 13.2536 16.2417 13.2562V13.2591L16.2407 13.2601C16.2268 13.3038 16.2077 13.3449 16.187 13.3841C16.5566 14.5982 16.4051 15.8801 15.5122 16.805C14.0254 18.345 11.4753 17.8696 9.86373 16.3138C9.18414 15.6576 8.69133 14.8276 8.45944 13.972C8.05454 13.5525 7.64913 13.397 7.38424 13.3831C7.59712 13.8598 7.7328 14.345 7.7778 14.8138C7.86052 15.6775 7.64387 16.5969 6.91061 17.1702C6.17739 17.7433 5.23363 17.7322 4.41549 17.4437C3.58744 17.1515 2.77404 16.5464 2.13327 15.7269C1.49254 14.9074 1.10184 13.972 1.01803 13.098C0.936885 12.25 1.1447 11.3498 1.84616 10.7747C2.09807 9.97534 2.5868 8.4405 3.06979 6.96224C3.59658 5.34994 4.14583 3.71844 4.3608 3.25618C4.68344 2.56265 5.10288 2.01575 5.68893 1.79134ZM13.7807 12.2572C12.4881 11.0094 10.9295 11.0284 10.2534 11.7288C9.57757 12.4294 9.61347 13.987 10.9057 15.2347C12.1981 16.4823 13.7567 16.464 14.4331 15.764C15.1092 15.0636 15.0731 13.505 13.7807 12.2572ZM3.88229 11.8841C3.3528 11.6973 2.99993 11.7759 2.80905 11.9251C2.61843 12.0746 2.45867 12.3971 2.51217 12.9554C2.5648 13.5041 2.82326 14.1742 3.31491 14.8031C3.8066 15.4319 4.39472 15.8452 4.91452 16.0286C5.44348 16.2152 5.79586 16.1376 5.98678 15.9886C6.17756 15.8394 6.33808 15.5159 6.28463 14.9574C6.23201 14.4087 5.97256 13.7385 5.48092 13.1097C4.98934 12.481 4.40201 12.0676 3.88229 11.8841ZM11.9194 14.7786C12.0925 14.5637 12.4074 14.5295 12.6225 14.7025C12.661 14.7334 12.7453 14.7848 12.8491 14.8284C12.8979 14.849 12.9439 14.8642 12.9819 14.8743C13.0173 14.8837 13.0359 14.8858 13.0395 14.8861C13.3156 14.8914 13.5361 15.1197 13.5307 15.3958C13.5253 15.6718 13.2969 15.8912 13.021 15.8861C12.823 15.8822 12.6163 15.8145 12.4614 15.7493C12.2997 15.6813 12.1271 15.5876 11.9956 15.4818C11.7806 15.3087 11.7465 14.9937 11.9194 14.7786ZM3.50924 12.1361L3.57955 12.1497L3.6733 12.1878C3.88055 12.2932 3.99317 12.533 3.92916 12.7659C3.90752 12.8445 3.8653 12.9118 3.81393 12.9681C3.81694 12.9884 3.81922 13.0124 3.82467 13.0384C3.87694 13.2878 4.02405 13.6447 4.28561 13.9671C4.45941 14.1814 4.42741 14.4972 4.21334 14.6712C3.99906 14.8451 3.68332 14.812 3.50924 14.598C3.14412 14.1481 2.92999 13.6431 2.84616 13.2425C2.80618 13.0513 2.78536 12.8362 2.82565 12.6468C2.84499 12.5561 2.89269 12.4054 3.02291 12.2845C3.16403 12.1538 3.34353 12.1108 3.50924 12.1361ZM11.0669 11.7454C11.2971 11.7138 11.5265 11.8478 11.6069 12.0755C11.6924 12.3193 11.576 12.5828 11.3471 12.6908C11.3428 12.696 11.3346 12.705 11.3256 12.721C11.2978 12.7706 11.2672 12.8577 11.2592 12.9779C11.244 13.2098 11.3168 13.5618 11.6518 13.9544C11.831 14.1644 11.806 14.4802 11.5962 14.6595C11.3862 14.8387 11.0704 14.8145 10.8911 14.6048C10.4014 14.0312 10.2273 13.4258 10.2612 12.9115C10.2779 12.6589 10.3462 12.4249 10.4546 12.2318C10.5444 12.0719 10.6842 11.9039 10.8803 11.807L10.9672 11.7699L11.0669 11.7454ZM5.92428 5.79427C5.92428 5.2357 6.35593 5.23572 6.35592 5.79427C6.35593 6.35264 6.57034 6.69727 6.64401 6.78743C6.71763 6.87749 6.84534 7.15945 7.30416 7.1595C7.76238 7.15988 7.76238 7.65527 7.30416 7.65559C6.84534 7.65559 6.77205 7.872 6.64401 8.02864C6.51604 8.18525 6.35794 8.41728 6.35788 8.96126C6.35788 9.50588 5.92623 9.51978 5.92623 8.96126C5.92613 8.40366 5.65582 8.067 5.62448 8.02864C5.59372 7.99102 5.27579 7.65582 4.91647 7.65559C4.557 7.65559 4.56049 7.1595 4.91647 7.1595C5.27178 7.15932 5.50406 6.87024 5.57174 6.78743C5.63909 6.70504 5.92426 6.3528 5.92428 5.79427Z" 50 - fill="currentColor" 51 - /> 52 - </svg> 53 - ); 54 - }; 55 - 56 - export const ReaderRead = (props: Props) => { 57 - return ( 58 - <svg 59 - width="24" 60 - height="24" 61 - viewBox="0 0 24 24" 62 - fill="none" 63 - xmlns="http://www.w3.org/2000/svg" 64 - {...props} 65 - > 66 - <path 67 - d="M5.3939 5.10098C6.94949 3.45228 9.5127 3.07248 11.1166 4.58535C11.8822 5.30769 12.2281 6.27357 12.2132 7.26504C12.4456 7.20657 12.6982 7.16362 12.9515 7.15469C13.2516 7.14413 13.6091 7.17916 13.9379 7.34707C14.6404 7.7064 15.0081 8.33088 15.142 8.9418C16.5998 8.30589 18.3571 8.43563 19.7631 9.42032C21.8907 10.9106 22.4521 13.8268 20.9711 15.9418C20.5099 16.6002 19.9071 17.0981 19.2328 17.4281C19.2725 17.8704 19.3144 18.3372 19.389 18.8285C19.4945 19.5226 19.6505 20.0808 19.8754 20.4223C20.103 20.7681 20.0071 21.2335 19.6615 21.4613C19.3156 21.6891 18.8503 21.5932 18.6224 21.2475C18.2076 20.6174 18.0171 19.7803 17.9066 19.0531C17.8466 18.658 17.8029 18.2404 17.7679 17.8647C16.6347 18.0087 15.441 17.7451 14.4291 17.0365C12.3744 15.5975 11.7805 12.8288 13.0756 10.7358L11.4466 9.68106C11.2965 9.90817 11.1261 10.1258 10.9349 10.3285C9.3795 11.9772 6.81619 12.3575 5.21226 10.8451C4.85829 10.5112 4.59328 10.1249 4.41246 9.70841C4.12505 9.84619 3.7775 10.0195 3.40464 10.2016C3.08808 10.3561 2.76192 10.5118 2.47203 10.6361C2.20246 10.7518 1.89418 10.872 1.63999 10.9145C1.23165 10.9825 0.845053 10.7066 0.776711 10.2982C0.7085 9.88981 0.984507 9.50334 1.39292 9.43497C1.44871 9.42563 1.60626 9.37614 1.8812 9.25821C2.1356 9.14908 2.43339 9.00579 2.74644 8.85294C3.20082 8.63107 3.71706 8.37021 4.11265 8.19278C4.12147 7.09833 4.57394 5.97021 5.3939 5.10098ZM18.9027 10.6488C17.7826 9.86431 16.3636 9.87558 15.3129 10.5473C16.188 11.0878 17.0483 11.6416 17.7211 12.0824C18.1169 12.3418 18.449 12.5624 18.682 12.7182C18.7984 12.796 18.8907 12.8585 18.9535 12.9008C18.9844 12.9216 19.0085 12.9377 19.0248 12.9486C19.0328 12.9541 19.0392 12.9585 19.0433 12.9613C19.0454 12.9627 19.0471 12.9645 19.0482 12.9652H19.0492V12.9662C19.2776 13.1212 19.3377 13.4321 19.183 13.6606C19.0279 13.8888 18.7161 13.9473 18.4877 13.7924L18.4867 13.7934C18.4857 13.7927 18.4847 13.7908 18.4828 13.7895C18.4789 13.7868 18.4729 13.7829 18.4652 13.7777C18.4493 13.767 18.4248 13.7507 18.3939 13.7299C18.3771 13.7186 18.3582 13.7059 18.3373 13.6918C18.3409 13.7007 18.3455 13.7094 18.349 13.7182C18.4391 13.9478 18.4846 14.151 18.514 14.3129C18.5488 14.5038 18.5528 14.5587 18.5697 14.6166C18.6475 14.8814 18.4955 15.1597 18.2308 15.2377C17.9663 15.3152 17.6888 15.1642 17.6107 14.8998C17.5741 14.7753 17.5483 14.5898 17.5306 14.4926C17.5076 14.366 17.4762 14.2309 17.4183 14.0834C17.3064 13.7986 17.0738 13.4138 16.516 12.9633L14.3353 11.5512C13.4914 12.949 13.8764 14.8184 15.2894 15.808C16.7626 16.8395 18.752 16.4941 19.7416 15.0815C20.731 13.6686 20.3757 11.6807 18.9027 10.6488ZM10.0873 5.67715C9.23327 4.87156 7.62151 4.92548 6.48472 6.13028C5.34805 7.33535 5.38756 8.94767 6.24156 9.75333C7.09567 10.5587 8.70742 10.5041 9.8441 9.29923C9.97166 9.16397 10.0826 9.02211 10.181 8.87833C8.43436 8.11224 8.03413 8.1053 7.73863 8.15372C7.49417 8.19384 7.43342 8.19931 7.34215 8.21719C7.27583 8.2302 7.20421 8.24884 7.00426 8.31387C6.74185 8.39904 6.45989 8.25486 6.37437 7.99258C6.28923 7.73019 6.43242 7.44723 6.69469 7.36172C6.90022 7.29488 7.02051 7.26011 7.14976 7.23477C7.25177 7.2148 7.38857 7.1961 7.5648 7.16739L7.50523 7.1293C7.27353 6.9792 7.20675 6.66966 7.3568 6.43789C7.50691 6.20642 7.81653 6.14058 8.0482 6.29043L10.6127 7.95059C10.8517 7.07384 10.6453 6.20394 10.0873 5.67715ZM13.0043 8.65372C12.9388 8.65604 12.868 8.66411 12.7933 8.67618L13.6478 9.14883C13.5794 8.94601 13.4513 8.7833 13.2552 8.68301C13.2369 8.67373 13.1626 8.64818 13.0043 8.65372Z" 32 + d="M12.379 1.43198C13.4689 1.41079 14.1368 1.86924 14.4992 2.30308C15.0424 2.95384 15.1168 3.72838 15.0753 4.3021C17.3983 5.25881 18.4631 7.59734 18.9484 10.3275C19.1445 11.0495 19.539 11.3859 20.0538 11.7269C20.5738 12.0711 21.4604 12.5232 21.9367 13.4847C22.7585 15.1448 22.219 17.3154 20.3732 18.472C19.6405 18.9311 18.7598 19.4667 17.4933 19.8783C16.8876 20.0751 16.2004 20.2389 15.4054 20.3587C15.0286 21.64 13.8299 22.5687 12.422 22.5687C11.0175 22.5684 9.8201 21.6443 9.44056 20.3675C8.62793 20.2471 7.93094 20.079 7.31849 19.8773C6.0667 19.465 5.20053 18.928 4.47279 18.472C2.62669 17.3153 2.08725 15.1449 2.90931 13.4847C3.38819 12.5179 4.18479 12.1099 4.72669 11.7923C5.25122 11.485 5.62837 11.2249 5.88294 10.6478C6.04112 9.31375 6.33609 8.10299 6.82923 7.0814C7.44748 5.8009 8.37993 4.81743 9.70521 4.27671C9.68221 3.73659 9.78903 3.07493 10.2296 2.4769C10.3087 2.36964 10.5055 2.10112 10.8654 1.86558C11.2304 1.62673 11.7262 1.44477 12.379 1.43198ZM13.963 20.515C13.4751 20.5481 12.9528 20.5667 12.3917 20.5667C11.8542 20.5667 11.3526 20.5494 10.8839 20.5189C11.2161 20.9991 11.7775 21.3185 12.422 21.3187C13.0684 21.3187 13.6311 20.9974 13.963 20.515ZM12.4035 2.93198C12.0278 2.93931 11.807 3.03915 11.6827 3.12046C11.5541 3.20485 11.4861 3.29613 11.4357 3.3646C11.2306 3.64338 11.1781 3.98084 11.2101 4.30503C11.233 4.53653 11.3069 4.74354 11.3361 4.8021C11.5745 5.09978 11.5517 5.53674 11.2716 5.80894C10.9739 6.09788 10.498 6.09183 10.2101 5.79526C10.1865 5.77095 10.1678 5.74218 10.1466 5.71519C9.27113 6.10725 8.63618 6.78165 8.17884 7.72886C7.627 8.87197 7.33861 10.4065 7.25599 12.2748C7.24499 12.7149 7.1279 13.2121 6.98646 13.6273C6.84551 14.0408 6.6358 14.3817 6.31166 14.7435C6.03482 15.0524 5.56101 15.0794 5.25306 14.8041C4.9451 14.5283 4.91961 14.0536 5.19642 13.7445C5.40901 13.5071 5.50261 13.3391 5.56751 13.1488C5.58207 13.1061 5.59391 13.0608 5.60658 13.013C5.56502 13.0379 5.52488 13.0636 5.48451 13.0873C4.86861 13.4481 4.50111 13.6519 4.25404 14.1507C3.78545 15.0972 4.05431 16.439 5.26966 17.2005C5.98211 17.6469 6.71946 18.1005 7.78822 18.4525C8.85905 18.8051 10.298 19.0667 12.3917 19.0667C14.4855 19.0667 15.9412 18.8051 17.0294 18.4515C18.1149 18.0987 18.8688 17.6438 19.5763 17.2005C20.7913 16.4391 21.0603 15.0971 20.5919 14.1507C20.3558 13.674 19.9814 13.4704 19.338 13.0501C19.3917 13.2186 19.4731 13.4853 19.6447 13.5697C20.0162 13.7509 20.17 14.2002 19.9874 14.5726C19.8047 14.9447 19.3551 15.0992 18.9835 14.9183C18.5687 14.716 18.3022 14.3715 18.1329 14.0541C17.9627 13.7344 17.8602 13.3869 17.796 13.0882C17.7309 12.7851 17.6995 12.5025 17.6837 12.2982C17.639 11.7172 17.5904 11.1217 17.4591 10.5531L17.465 10.5511C17.0282 8.11865 16.1654 6.46451 14.6427 5.75327C14.4333 5.97064 14.1064 6.05064 13.8126 5.9271C13.4133 5.75888 13.2861 5.33204 13.4152 4.94468C13.4307 4.90565 13.536 4.62297 13.5734 4.29331C13.6186 3.89372 13.5628 3.5267 13.3458 3.26694C13.2493 3.15138 13.0022 2.92054 12.4035 2.93198ZM10.2716 16.0873C11.742 16.2881 12.8605 16.3032 13.716 16.2064C14.1272 16.1602 14.4984 16.4563 14.5451 16.8675C14.5915 17.279 14.2954 17.651 13.8839 17.6976C12.8712 17.8122 11.6232 17.786 10.0685 17.5736C9.65818 17.5175 9.37096 17.1392 9.42689 16.7289C9.48296 16.3185 9.86121 16.0312 10.2716 16.0873ZM16.09 15.7962C16.4415 15.5778 16.9034 15.6852 17.1222 16.0365C17.3407 16.3881 17.2334 16.8509 16.882 17.0697C16.6801 17.1952 16.385 17.321 16.1437 17.388C15.7448 17.4987 15.3318 17.2644 15.2208 16.8656C15.1102 16.4666 15.3434 16.0537 15.7423 15.9427C15.8619 15.9095 16.0227 15.8381 16.09 15.7962Z" 68 33 fill="currentColor" 69 34 /> 70 35 </svg>
+18
components/Icons/RSSTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + export const RSSTiny = (props: Props) => { 3 + return ( 4 + <svg 5 + width="16" 6 + height="16" 7 + viewBox="0 0 16 16" 8 + fill="none" 9 + xmlns="http://www.w3.org/2000/svg" 10 + {...props} 11 + > 12 + <path 13 + d="M2.82098 5.7636C6.84291 5.76364 10.2363 8.92669 10.2364 13.179C10.2364 13.8688 9.67713 14.428 8.98738 14.428C8.29764 14.428 7.73841 13.8688 7.73837 13.179C7.7383 10.3543 5.5118 8.26167 2.82098 8.26163C2.13119 8.26163 1.572 7.7024 1.57196 7.01262C1.57196 6.32281 2.13116 5.7636 2.82098 5.7636ZM2.82098 1.57196C9.12441 1.572 14.428 6.52137 14.428 13.179C14.428 13.8688 13.8688 14.428 13.179 14.428C12.4892 14.428 11.93 13.8688 11.93 13.179C11.93 7.94901 7.7933 4.07003 2.82098 4.06999C2.13116 4.06999 1.57196 3.51079 1.57196 2.82098C1.57196 2.13116 2.13116 1.57196 2.82098 1.57196ZM3.93094 10.6066C4.82318 10.6067 5.54639 11.3299 5.54649 12.2221C5.54649 13.1145 4.82325 13.8382 3.93094 13.8383C3.03853 13.8383 2.31478 13.1145 2.31478 12.2221C2.31489 11.3298 3.03859 10.6066 3.93094 10.6066Z" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+4 -3
components/Icons/ReaderSmall.tsx
··· 3 3 export const ReaderUnreadSmall = (props: Props) => { 4 4 return ( 5 5 <svg 6 - width="25" 6 + width="24" 7 7 height="24" 8 - viewBox="0 0 25 24" 8 + viewBox="0 0 24 24" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 - d="M2.82968 18.0472C2.82987 17.3641 3.41641 17.3642 3.41659 18.0472C3.41658 18.7307 3.7081 19.1528 3.80819 19.263C3.90827 19.3732 4.08109 19.7179 4.70468 19.7181C5.32851 19.7182 5.32851 20.3255 4.70468 20.3255C4.08111 20.3256 3.98225 20.5899 3.80819 20.7816C3.63417 20.9732 3.41859 21.2572 3.41854 21.9232C3.41854 22.5896 2.83163 22.6066 2.83163 21.9232C2.83155 21.2398 2.4633 20.8276 2.42147 20.7816C2.37905 20.735 1.94693 20.3255 1.45858 20.3255C0.97001 20.3254 0.974762 19.7182 1.45858 19.7181C1.9419 19.7181 2.25811 19.3644 2.35018 19.263C2.44176 19.1622 2.82968 18.7307 2.82968 18.0472ZM18.8189 13.7962C18.8191 13.1543 19.3158 13.1543 19.316 13.7962C19.316 14.4386 19.5624 14.8352 19.6471 14.9388C19.7317 15.0425 19.8778 15.3663 20.4049 15.3665C20.9325 15.3666 20.9325 15.9378 20.4049 15.9378C19.8775 15.9379 19.7943 16.1864 19.6471 16.3665C19.4999 16.5466 19.3171 16.8131 19.317 17.4388C19.317 18.0651 18.8209 18.0811 18.8209 17.4388C18.8207 16.7986 18.5111 16.4117 18.4742 16.3665C18.4388 16.3233 18.073 15.9381 17.6598 15.9378C17.2464 15.9378 17.2504 15.3665 17.6598 15.3665C18.0686 15.3663 18.336 15.0338 18.4137 14.9388C18.4911 14.844 18.8189 14.4386 18.8189 13.7962ZM6.10897 1.79134C6.74065 1.54967 7.31924 1.76772 7.73007 2.04427C8.13472 2.31686 8.48886 2.71494 8.77304 3.07649C9.05968 3.44125 9.32671 3.83827 9.52304 4.11653C9.76174 4.45493 9.68165 4.92361 9.34335 5.16243C9.0051 5.40111 8.53735 5.31974 8.29843 4.98177C8.07551 4.66583 7.84739 4.32673 7.59237 4.00227C7.33511 3.67506 7.09903 3.42822 6.89315 3.28938C6.79455 3.22296 6.72689 3.19837 6.69101 3.19075C6.66479 3.18528 6.65529 3.18881 6.64511 3.1927C6.60428 3.20859 6.40334 3.32114 6.13925 3.88899C6.01867 4.14843 5.71704 5.01402 5.35214 6.10677C5.27851 6.12987 5.22515 6.14661 5.21835 6.15462C5.21489 6.15884 5.21125 6.16214 5.20858 6.16536C4.91664 6.19308 4.63293 6.31734 4.41464 6.54427C4.17686 6.79162 4.06902 7.10768 4.06796 7.40462C4.067 7.70171 4.17276 8.02065 4.41268 8.27083C4.46886 8.32937 4.52964 8.38044 4.59335 8.42513C4.37591 9.09776 4.17095 9.74532 3.99765 10.2884C4.2707 10.3144 4.54179 10.3779 4.80038 10.4691C5.5814 10.7446 6.34697 11.3005 6.96933 12.0501C7.13971 11.9646 7.3231 11.9158 7.50936 11.8929C7.9461 11.8392 8.39353 11.9251 8.81893 12.1146C8.94078 11.5922 9.19242 11.1032 9.59433 10.6868C11.081 9.14683 13.6312 9.62241 15.2428 11.1781C15.3753 11.306 15.4999 11.4413 15.6178 11.5814C15.7723 11.0931 15.9613 10.4983 16.1588 9.87044C16.6539 8.29641 17.2076 6.52428 17.4498 5.71028C17.7531 4.69126 18.1848 3.91964 18.8023 3.49056C19.4769 3.02218 20.2194 3.06179 20.859 3.34017C21.4671 3.60494 22.0253 4.09649 22.5025 4.63802C22.9884 5.18942 23.4343 5.84393 23.8062 6.50032C24.0101 6.86052 23.884 7.31757 23.524 7.52181C23.1638 7.72592 22.7059 7.59954 22.5016 7.23958C22.1686 6.6521 21.7805 6.08653 21.3775 5.62923C20.9661 5.16239 20.5798 4.85528 20.2603 4.71614C19.9724 4.5908 19.8033 4.62119 19.6568 4.72298C19.4532 4.86476 19.1524 5.24367 18.8863 6.13802C18.6401 6.9656 18.0833 8.74963 17.5894 10.3197C17.3421 11.1061 17.1093 11.8418 16.9391 12.3802C16.854 12.6494 16.7853 12.87 16.7369 13.0228C16.7128 13.0988 16.6941 13.1581 16.6812 13.1986C16.6748 13.2188 16.6689 13.235 16.6656 13.2454C16.6641 13.2501 16.6625 13.2536 16.6617 13.2562V13.2591L16.6607 13.2601C16.6469 13.3038 16.6277 13.3449 16.607 13.3841C16.9766 14.5982 16.8251 15.8801 15.9322 16.805C14.4454 18.345 11.8953 17.8696 10.2838 16.3138C9.60418 15.6576 9.11137 14.8276 8.87948 13.972C8.47459 13.5525 8.06917 13.397 7.80429 13.3831C8.01717 13.8598 8.15284 14.345 8.19784 14.8138C8.28056 15.6775 8.06391 16.5969 7.33065 17.1702C6.59744 17.7433 5.65367 17.7322 4.83554 17.4437C4.00748 17.1515 3.19408 16.5464 2.55331 15.7269C1.91258 14.9074 1.52188 13.972 1.43808 13.098C1.35693 12.25 1.56474 11.3498 2.2662 10.7747C2.51811 9.97534 3.00684 8.4405 3.48983 6.96224C4.01662 5.34994 4.56587 3.71844 4.78085 3.25618C5.10348 2.56265 5.52293 2.01575 6.10897 1.79134ZM14.2008 12.2572C12.9082 11.0094 11.3496 11.0284 10.6734 11.7288C9.99762 12.4294 10.0335 13.987 11.3258 15.2347C12.6181 16.4823 14.1767 16.464 14.8531 15.764C15.5292 15.0636 15.4931 13.505 14.2008 12.2572ZM4.30233 11.8841C3.77284 11.6973 3.41998 11.7759 3.22909 11.9251C3.03847 12.0746 2.87871 12.3971 2.93222 12.9554C2.98485 13.5041 3.24331 14.1742 3.73495 14.8031C4.22664 15.4319 4.81476 15.8452 5.33456 16.0286C5.86353 16.2152 6.2159 16.1376 6.40683 15.9886C6.59761 15.8394 6.75812 15.5159 6.70468 14.9574C6.65205 14.4087 6.3926 13.7385 5.90097 13.1097C5.40938 12.481 4.82205 12.0676 4.30233 11.8841ZM12.3394 14.7786C12.5125 14.5637 12.8275 14.5295 13.0426 14.7025C13.081 14.7334 13.1653 14.7848 13.2691 14.8284C13.318 14.849 13.3639 14.8642 13.4019 14.8743C13.4373 14.8837 13.456 14.8858 13.4596 14.8861C13.7357 14.8914 13.9561 15.1197 13.9508 15.3958C13.9454 15.6718 13.7169 15.8912 13.441 15.8861C13.243 15.8822 13.0364 15.8145 12.8814 15.7493C12.7197 15.6813 12.5471 15.5876 12.4156 15.4818C12.2007 15.3087 12.1665 14.9937 12.3394 14.7786ZM3.92929 12.1361L3.9996 12.1497L4.09335 12.1878C4.3006 12.2932 4.41321 12.533 4.34921 12.7659C4.32756 12.8445 4.28534 12.9118 4.23397 12.9681C4.23698 12.9884 4.23927 13.0124 4.24472 13.0384C4.29698 13.2878 4.4441 13.6447 4.70565 13.9671C4.87945 14.1814 4.84746 14.4972 4.63339 14.6712C4.41911 14.8451 4.10336 14.812 3.92929 14.598C3.56417 14.1481 3.35003 13.6431 3.2662 13.2425C3.22622 13.0513 3.2054 12.8362 3.24569 12.6468C3.26503 12.5561 3.31274 12.4054 3.44296 12.2845C3.58407 12.1538 3.76357 12.1108 3.92929 12.1361ZM11.4869 11.7454C11.7171 11.7138 11.9466 11.8478 12.0269 12.0755C12.1125 12.3193 11.996 12.5828 11.7672 12.6908C11.7629 12.696 11.7547 12.705 11.7457 12.721C11.7178 12.7706 11.6872 12.8577 11.6793 12.9779C11.6641 13.2098 11.7368 13.5618 12.0719 13.9544C12.2511 14.1644 12.226 14.4802 12.0162 14.6595C11.8063 14.8387 11.4905 14.8145 11.3111 14.6048C10.8215 14.0312 10.6473 13.4258 10.6812 12.9115C10.698 12.6589 10.7662 12.4249 10.8746 12.2318C10.9645 12.0719 11.1042 11.9039 11.3004 11.807L11.3873 11.7699L11.4869 11.7454ZM6.34433 5.79427C6.34433 5.2357 6.77597 5.23572 6.77597 5.79427C6.77598 6.35264 6.99039 6.69727 7.06405 6.78743C7.13767 6.87749 7.26539 7.15945 7.72421 7.1595C8.18243 7.15988 8.18243 7.65527 7.72421 7.65559C7.26539 7.65559 7.19209 7.872 7.06405 8.02864C6.93609 8.18525 6.77798 8.41728 6.77792 8.96126C6.77792 9.50588 6.34628 9.51978 6.34628 8.96126C6.34618 8.40366 6.07586 8.067 6.04452 8.02864C6.01377 7.99102 5.69583 7.65582 5.33651 7.65559C4.97705 7.65559 4.98054 7.1595 5.33651 7.1595C5.69183 7.15932 5.92411 6.87024 5.99179 6.78743C6.05914 6.70504 6.3443 6.3528 6.34433 5.79427Z" 14 + d="M2.73574 18.3601C2.73594 17.677 3.32247 17.677 3.32266 18.3601C3.32265 19.0436 3.61416 19.4657 3.71426 19.5759C3.81434 19.6861 3.98716 20.0308 4.61074 20.031C5.23458 20.031 5.23458 20.6384 4.61074 20.6384C3.98718 20.6385 3.88832 20.9028 3.71426 21.0945C3.54023 21.2861 3.32466 21.5701 3.32461 22.2361C3.32461 22.9025 2.7377 22.9195 2.7377 22.2361C2.73762 21.5527 2.36936 21.1405 2.32754 21.0945C2.28512 21.0478 1.853 20.6384 1.36465 20.6384C0.876077 20.6383 0.880828 20.0311 1.36465 20.031C1.84797 20.031 2.16417 19.6773 2.25625 19.5759C2.34783 19.4751 2.73574 19.0436 2.73574 18.3601ZM18.725 14.1091C18.7252 13.4672 19.2219 13.4672 19.2221 14.1091C19.2221 14.7515 19.4685 15.1481 19.5531 15.2517C19.6378 15.3554 19.7838 15.6792 20.3109 15.6794C20.8386 15.6795 20.8386 16.2507 20.3109 16.2507C19.7836 16.2508 19.7003 16.4993 19.5531 16.6794C19.406 16.8595 19.2231 17.126 19.223 17.7517C19.223 18.378 18.727 18.394 18.727 17.7517C18.7268 17.1115 18.4172 16.7246 18.3803 16.6794C18.3449 16.6361 17.9791 16.2509 17.5658 16.2507C17.1524 16.2507 17.1564 15.6794 17.5658 15.6794C17.9747 15.6792 18.2421 15.3467 18.3197 15.2517C18.3972 15.1569 18.725 14.7515 18.725 14.1091ZM6.01504 2.10422C6.64672 1.86255 7.22531 2.08061 7.63613 2.35715C8.04078 2.62974 8.39493 3.02782 8.6791 3.38937C8.96575 3.75413 9.23277 4.15115 9.4291 4.42941C9.66781 4.76781 9.58772 5.23649 9.24942 5.47531C8.91117 5.71399 8.44342 5.63262 8.20449 5.29465C7.98157 4.97871 7.75345 4.63961 7.49844 4.31516C7.24118 3.98794 7.00509 3.7411 6.79922 3.60227C6.70062 3.53584 6.63296 3.51125 6.59707 3.50363C6.57086 3.49816 6.56136 3.5017 6.55117 3.50559C6.51034 3.52147 6.30941 3.63402 6.04531 4.20187C5.92473 4.46131 5.6231 5.3269 5.2582 6.41965C5.18458 6.44275 5.13122 6.45949 5.12442 6.4675C5.12096 6.47173 5.11732 6.47502 5.11465 6.47824C4.82271 6.50596 4.53899 6.63022 4.3207 6.85715C4.08293 7.1045 3.97509 7.42056 3.97403 7.7175C3.97307 8.01459 4.07883 8.33353 4.31875 8.58371C4.37493 8.64225 4.43571 8.69333 4.49942 8.73801C4.28198 9.41064 4.07702 10.0582 3.90371 10.6013C4.17677 10.6273 4.44785 10.6908 4.70645 10.782C5.48747 11.0575 6.25304 11.6133 6.87539 12.363C7.04578 12.2775 7.22917 12.2287 7.41543 12.2058C7.85216 12.1521 8.2996 12.238 8.725 12.4275C8.84684 11.905 9.09849 11.4161 9.50039 10.9997C10.9871 9.45971 13.5373 9.9353 15.1488 11.4909C15.2814 11.6189 15.406 11.7542 15.5238 11.8943C15.6783 11.406 15.8673 10.8112 16.0648 10.1833C16.56 8.6093 17.1137 6.83716 17.3559 6.02316C17.6591 5.00414 18.0908 4.23252 18.7084 3.80344C19.383 3.33506 20.1254 3.37467 20.765 3.65305C21.3731 3.91782 21.9314 4.40937 22.4086 4.9509C22.8945 5.5023 23.3404 6.15682 23.7123 6.8132C23.9161 7.1734 23.7901 7.63045 23.4301 7.83469C23.0699 8.0388 22.612 7.91243 22.4076 7.55246C22.0747 6.96499 21.6866 6.39942 21.2836 5.94211C20.8721 5.47527 20.4858 5.16816 20.1664 5.02902C19.8785 4.90368 19.7094 4.93407 19.5629 5.03586C19.3593 5.17764 19.0585 5.55655 18.7924 6.4509C18.5462 7.27848 17.9894 9.06251 17.4955 10.6325C17.2481 11.419 17.0154 12.1546 16.8451 12.6931C16.76 12.9622 16.6914 13.1829 16.643 13.3357C16.6189 13.4117 16.6001 13.471 16.5873 13.5114C16.5809 13.5317 16.575 13.5479 16.5717 13.5583C16.5702 13.563 16.5686 13.5665 16.5678 13.5691V13.572L16.5668 13.573C16.5529 13.6167 16.5338 13.6578 16.5131 13.697C16.8827 14.9111 16.7312 16.1929 15.8383 17.1179C14.3515 18.6579 11.8014 18.1824 10.1898 16.6267C9.51025 15.9705 9.01744 15.1405 8.78555 14.2849C8.38066 13.8654 7.97524 13.7099 7.71035 13.696C7.92323 14.1726 8.05891 14.6578 8.10391 15.1267C8.18663 15.9904 7.96998 16.9098 7.23672 17.4831C6.5035 18.0562 5.55974 18.0451 4.7416 17.7566C3.91355 17.4644 3.10015 16.8593 2.45938 16.0398C1.81865 15.2203 1.42795 14.2849 1.34414 13.4109C1.263 12.5629 1.47081 11.6627 2.17227 11.0876C2.42418 10.2882 2.91291 8.75338 3.3959 7.27512C3.92269 5.66282 4.47194 4.03132 4.68692 3.56906C5.00955 2.87553 5.42899 2.32864 6.01504 2.10422ZM14.1068 12.57C12.8142 11.3223 11.2556 11.3413 10.5795 12.0417C9.90368 12.7423 9.93958 14.2999 11.2318 15.5476C12.5242 16.7952 14.0828 16.7769 14.7592 16.0769C15.4353 15.3765 15.3992 13.8179 14.1068 12.57ZM4.2084 12.197C3.67891 12.0102 3.32604 12.0888 3.13516 12.238C2.94454 12.3875 2.78478 12.71 2.83828 13.2683C2.89091 13.8169 3.14937 14.4871 3.64102 15.1159C4.13271 15.7448 4.72083 16.1581 5.24063 16.3415C5.76959 16.5281 6.12197 16.4505 6.31289 16.3015C6.50367 16.1522 6.66419 15.8287 6.61074 15.2702C6.55812 14.7216 6.29867 14.0514 5.80703 13.4226C5.31545 12.7939 4.72812 12.3805 4.2084 12.197ZM12.2455 15.0915C12.4186 14.8766 12.7336 14.8424 12.9486 15.0154C12.9871 15.0463 13.0714 15.0977 13.1752 15.1413C13.224 15.1618 13.27 15.1771 13.308 15.1872C13.3434 15.1966 13.362 15.1986 13.3656 15.1989C13.6417 15.2043 13.8622 15.4326 13.8568 15.7087C13.8514 15.9846 13.623 16.2041 13.3471 16.1989C13.1491 16.1951 12.9425 16.1273 12.7875 16.0622C12.6258 15.9942 12.4532 15.9004 12.3217 15.7946C12.1068 15.6215 12.0726 15.3066 12.2455 15.0915ZM3.83535 12.4489L3.90567 12.4626L3.99942 12.5007C4.20667 12.6061 4.31928 12.8459 4.25528 13.0788C4.23363 13.1573 4.19141 13.2246 4.14004 13.281C4.14305 13.3013 4.14533 13.3252 4.15078 13.3513C4.20305 13.6007 4.35016 13.9576 4.61172 14.28C4.78552 14.4943 4.75352 14.8101 4.53945 14.9841C4.32517 15.158 4.00943 15.1249 3.83535 14.9109C3.47023 14.4609 3.2561 13.956 3.17227 13.5554C3.13229 13.3642 3.11147 13.1491 3.15176 12.9597C3.1711 12.8689 3.2188 12.7183 3.34903 12.5974C3.49014 12.4666 3.66964 12.4237 3.83535 12.4489ZM11.393 12.0583C11.6232 12.0267 11.8527 12.1607 11.933 12.3884C12.0186 12.6322 11.9021 12.8956 11.6732 13.0036C11.6689 13.0089 11.6608 13.0179 11.6518 13.0339C11.6239 13.0835 11.5933 13.1706 11.5854 13.2907C11.5701 13.5227 11.6429 13.8747 11.9779 14.2673C12.1572 14.4773 12.1321 14.7931 11.9223 14.9724C11.7123 15.1516 11.3965 15.1274 11.2172 14.9177C10.7276 14.3441 10.5534 13.7387 10.5873 13.2243C10.604 12.9718 10.6723 12.7377 10.7807 12.5446C10.8705 12.3848 11.0103 12.2168 11.2064 12.1198L11.2934 12.0827L11.393 12.0583ZM6.25039 6.10715C6.25039 5.54858 6.68204 5.5486 6.68203 6.10715C6.68205 6.66552 6.89645 7.01015 6.97012 7.10031C7.04374 7.19038 7.17145 7.47233 7.63028 7.47238C8.08849 7.47276 8.08849 7.96815 7.63028 7.96848C7.17145 7.96848 7.09816 8.18488 6.97012 8.34152C6.84216 8.49813 6.68405 8.73016 6.68399 9.27414C6.68399 9.81876 6.25235 9.83266 6.25235 9.27414C6.25224 8.71654 5.98193 8.37988 5.95059 8.34152C5.91984 8.3039 5.6019 7.9687 5.24258 7.96848C4.88311 7.96848 4.8866 7.47238 5.24258 7.47238C5.59789 7.4722 5.83017 7.18312 5.89785 7.10031C5.9652 7.01792 6.25037 6.66568 6.25039 6.10715Z" 14 15 fill="currentColor" 15 16 /> 16 17 </svg>
+37
components/Icons/RecommendTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const RecommendTinyFilled = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M13.8218 8.85542C13.9838 8.63176 14.2964 8.58118 14.5201 8.74312C14.7433 8.90516 14.7932 9.21786 14.6314 9.44136C12.9671 11.7399 10.7811 13.1142 9.07472 14.0947C8.83547 14.2321 8.52981 14.1491 8.3921 13.9101C8.25463 13.6707 8.33728 13.365 8.57667 13.2275C10.2589 12.2608 12.2881 10.9736 13.8218 8.85542ZM9.09327 2.90525C10.0113 2.2003 11.4161 2.21431 12.2886 2.61521C13.0365 2.95905 13.6929 3.5946 14.0044 4.62106C14.2614 5.46809 14.2169 6.28576 14.0044 7.17867C13.4531 9.49467 10.1475 11.7776 8.22413 12.8828C8.15152 12.9245 8.05431 12.9453 7.97315 12.9453C7.89219 12.9453 7.80343 12.9243 7.73096 12.8828C5.80749 11.7776 2.50174 9.49385 1.95065 7.1777C1.7383 6.28491 1.69376 5.46798 1.95065 4.62106C2.26221 3.59471 2.91764 2.95906 3.66551 2.61521C4.53812 2.21415 5.94374 2.19992 6.86181 2.90525C7.4145 3.32999 7.72613 3.72603 7.97315 4.14939C8.22018 3.72604 8.5406 3.32998 9.09327 2.90525ZM4.55418 3.84079C4.44015 3.58958 4.14441 3.47805 3.89305 3.59177C2.93793 4.0246 2.4787 5.35564 2.85105 6.64059C2.9282 6.90532 3.20525 7.05713 3.47019 6.98043C3.73523 6.9035 3.88869 6.62638 3.81199 6.36129C3.52801 5.38087 3.94973 4.66317 4.30516 4.50192C4.55654 4.38789 4.6681 4.09224 4.55418 3.84079Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + }; 20 + 21 + export const RecommendTinyEmpty = (props: Props) => { 22 + return ( 23 + <svg 24 + width="16" 25 + height="16" 26 + viewBox="0 0 16 16" 27 + fill="none" 28 + xmlns="http://www.w3.org/2000/svg" 29 + {...props} 30 + > 31 + <path 32 + d="M13.8215 8.85505C13.9834 8.63149 14.2961 8.58084 14.5197 8.74275C14.7432 8.90468 14.7928 9.21739 14.631 9.44099C12.9668 11.7395 10.7808 13.1138 9.0744 14.0943C8.83501 14.2318 8.52937 14.1491 8.39178 13.9097C8.25431 13.6703 8.33696 13.3647 8.57635 13.2271C10.2586 12.2605 12.2878 10.9733 13.8215 8.85505ZM4.12127 2.44392C5.05035 2.20462 6.17272 2.3143 7.04412 3.04744C7.33889 3.29547 7.62399 3.64884 7.85369 3.96833C7.89451 4.02512 7.93345 4.08237 7.97186 4.13826C8.22436 3.76381 8.53885 3.3457 8.86248 3.06501C9.80388 2.24888 11.1891 2.16939 12.1564 2.56501C12.9693 2.89763 13.663 3.49593 14.0002 4.60701C14.267 5.48669 14.2598 6.26139 14.0461 7.15974C13.7527 8.39225 12.7396 9.53682 11.6691 10.4703C10.5802 11.4198 9.3429 12.2265 8.47772 12.7681C8.47247 12.7714 8.46646 12.7748 8.46111 12.7779C8.43136 12.795 8.369 12.8315 8.30096 12.8619C8.2405 12.8889 8.11991 12.937 7.97576 12.9371C7.82229 12.9372 7.7007 12.8832 7.63885 12.8521C7.6045 12.8349 7.57372 12.8176 7.55291 12.8052C7.52605 12.7893 7.52018 12.7855 7.50701 12.7779C7.50235 12.7752 7.49792 12.7719 7.49334 12.7691C6.59506 12.2129 5.35778 11.3987 4.27654 10.4439C3.21273 9.50447 2.21958 8.35999 1.92693 7.13044C1.71321 6.23218 1.70502 5.4352 1.97186 4.55525C2.31285 3.43128 3.22341 2.67532 4.12127 2.44392ZM6.40057 3.81306C5.82954 3.33259 5.06002 3.23404 4.37029 3.41169C3.79433 3.56026 3.16381 4.07131 2.92889 4.84529C2.72085 5.53135 2.72051 6.14631 2.89959 6.899C3.11654 7.81042 3.90364 8.77988 4.93865 9.69392C5.94258 10.5805 7.10507 11.3507 7.98358 11.8961C8.83657 11.3611 9.99989 10.5988 11.0119 9.71638C12.0571 8.8049 12.8571 7.83679 13.0734 6.9283C13.2523 6.17606 13.2512 5.58411 13.0431 4.89802C12.8044 4.11102 12.3496 3.72381 11.7775 3.48982C11.1013 3.21328 10.1298 3.29025 9.51776 3.82087C9.10331 4.18037 8.63998 4.9218 8.40545 5.3238C8.3158 5.4772 8.1515 5.57185 7.97381 5.57185C7.79617 5.57171 7.63172 5.47723 7.54217 5.3238C7.43363 5.13777 7.25216 4.84479 7.04119 4.55134C6.82572 4.25167 6.5988 3.97993 6.40057 3.81306Z" 33 + fill="currentColor" 34 + /> 35 + </svg> 36 + ); 37 + };
+19
components/Icons/ShareTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ShareTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M14.294 2.09457C14.4677 2.02691 14.6645 2.06158 14.8048 2.18441C14.9451 2.30734 15.0054 2.4983 14.961 2.67953L12.8145 11.4481C12.7536 11.6967 12.5144 11.8588 12.2608 11.8241L7.56942 11.1766L5.33211 13.7664C5.20836 13.9096 5.01456 13.9711 4.83114 13.9246C4.6477 13.8781 4.50644 13.7316 4.4659 13.5467L3.68368 9.98324L1.212 8.00863C1.07265 7.89707 1.00353 7.71931 1.03035 7.54281C1.05731 7.36628 1.1765 7.21715 1.34285 7.15218L14.294 2.09457ZM4.70028 9.94417L5.12118 11.867L5.8409 10.2899L5.88094 10.2176C5.89632 10.1948 5.9137 10.1732 5.9327 10.1532L8.08407 7.8807L4.70028 9.94417ZM2.51375 7.76742L4.17391 9.09457L10.7677 5.07503C10.9816 4.9446 11.2595 4.99249 11.4171 5.18734C11.5746 5.38222 11.5631 5.66361 11.3907 5.84554L7.32723 10.1346L11.9493 10.7713L13.7598 3.37582L2.51375 7.76742Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+19
components/Icons/TagSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const TagSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M6.72467 12.065C7.3108 11.6577 8.11602 11.8032 8.5235 12.3893C8.93088 12.9754 8.78635 13.7806 8.20026 14.1881C7.72319 14.5197 7.10087 14.4854 6.66608 14.1451C5.6484 14.4853 4.81236 15.2231 4.31256 16.1021C3.73575 17.1169 3.65902 18.2144 4.12213 19.0484C4.47731 19.6876 5.18264 20.0117 6.20514 20.0758C7.22138 20.1393 8.37845 19.9287 9.3399 19.6812C10.7978 19.3061 11.89 19.606 12.584 20.2672C12.9149 20.5824 13.1265 20.9561 13.2393 21.3121C13.3463 21.6497 13.3853 22.0412 13.2784 22.3863C13.1557 22.7818 12.7354 23.003 12.3399 22.8805C11.9689 22.7654 11.7507 22.3889 11.8262 22.0162L11.8419 21.9518C11.8386 21.9605 11.8509 21.8957 11.8096 21.7652C11.7675 21.6322 11.6837 21.4816 11.5489 21.3531C11.3113 21.127 10.7896 20.8563 9.71295 21.1334C8.70042 21.394 7.3632 21.6522 6.11139 21.5738C4.86592 21.4958 3.52842 21.0674 2.81158 19.7779C2.02826 18.368 2.24712 16.6994 3.00787 15.3609C3.68152 14.176 4.82017 13.1659 6.2403 12.7066C6.32636 12.4555 6.49002 12.2282 6.72467 12.065ZM19.9464 11.7271C20.2297 11.5306 20.6186 11.6012 20.8155 11.8844C21.0123 12.1677 20.9424 12.5565 20.6592 12.7535L13.0342 18.0504C12.5945 18.3558 12.0716 18.5201 11.5362 18.5201H6.94928C6.60423 18.5201 6.32351 18.2401 6.3233 17.8951C6.32356 17.5503 6.60351 17.2704 6.9483 17.2701L11.5362 17.2711C11.8166 17.2711 12.091 17.184 12.3214 17.024L19.9464 11.7271ZM14.669 1.39902C15.4624 0.847523 16.5528 1.04335 17.1046 1.83652L21.3663 7.9664C21.9179 8.75983 21.722 9.85015 20.9288 10.4019L12.252 16.4352C11.8686 16.7016 11.4113 16.8423 10.9444 16.8375L6.69147 16.7935C6.27743 16.7892 5.94511 16.4498 5.94928 16.0357C5.95367 15.6217 6.293 15.2893 6.70709 15.2935L10.96 15.3375C11.1156 15.339 11.2678 15.2915 11.3956 15.2027L20.0723 9.1705C20.1855 9.09164 20.2136 8.93615 20.1348 8.82285L15.8731 2.69296C15.7943 2.57985 15.6387 2.55191 15.5255 2.63046L6.88873 8.63437C6.74865 8.73176 6.64607 8.87505 6.59772 9.03867L5.64459 12.2682C5.52741 12.6654 5.11018 12.893 4.71295 12.776C4.31582 12.6587 4.08894 12.2415 4.20612 11.8443L5.15826 8.61386C5.30323 8.1228 5.6119 7.69523 6.03229 7.40292L14.669 1.39902ZM14.086 5.54355C14.1404 5.5563 14.1874 5.59162 14.2149 5.64023L15.0626 7.14706L16.5167 5.84726C16.5594 5.80926 16.6162 5.79077 16.6729 5.79745C16.7299 5.80432 16.782 5.83608 16.8145 5.88339L17.4844 6.85995C17.5169 6.90725 17.5277 6.96644 17.5137 7.02206C17.4996 7.07758 17.4621 7.12477 17.4112 7.15097L15.6739 8.03964L16.796 9.40097C16.8316 9.44425 16.8477 9.5017 16.8399 9.55722C16.832 9.61254 16.8009 9.66223 16.7549 9.69394L15.9327 10.2574C15.8866 10.289 15.8299 10.3001 15.7755 10.2877C15.7208 10.275 15.6731 10.2398 15.6456 10.191L14.7823 8.65976L13.8008 9.58456C13.7863 9.5982 13.7699 9.60998 13.752 9.61874L12.4571 10.2516L13.5762 11.6109C13.6119 11.6542 13.628 11.7116 13.6202 11.7672C13.6123 11.8225 13.5812 11.8722 13.5352 11.9039L12.713 12.4674C12.6669 12.499 12.6092 12.5101 12.5547 12.4977C12.5007 12.4851 12.4545 12.4501 12.4268 12.4019L11.5499 10.8697L10.1046 12.1646C10.0618 12.203 10.0044 12.2222 9.94733 12.2154C9.89045 12.2085 9.83922 12.1767 9.8067 12.1295L9.1358 11.1529C9.10345 11.1057 9.09259 11.0463 9.10651 10.9908C9.12065 10.9351 9.1588 10.8871 9.21002 10.8609L10.9376 9.97812L9.84381 8.63925C9.80844 8.59592 9.79291 8.53934 9.80084 8.48398C9.80882 8.42869 9.83975 8.37888 9.8858 8.34726L10.7081 7.78378C10.7543 7.75217 10.8117 7.74085 10.8663 7.75351C10.9208 7.76625 10.9677 7.80144 10.9952 7.85019L11.8419 9.35312L12.8985 8.3746C12.9131 8.36104 12.9293 8.34908 12.9473 8.34042L14.1612 7.75742L13.0645 6.43027C13.0288 6.38701 13.0128 6.33052 13.0206 6.27499C13.0284 6.21943 13.0593 6.16905 13.1055 6.1373L13.9278 5.57382C13.9739 5.54231 14.0316 5.53098 14.086 5.54355Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+19
components/Icons/WriterSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const WriterSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M12.8808 22.0391C13.2948 22.0391 13.6304 22.3752 13.6308 22.7891C13.6306 23.203 13.2948 23.5389 12.8808 23.5391H4.92087C4.50702 23.5387 4.17194 23.203 4.17185 22.7891C4.17211 22.3751 4.5078 22.0391 4.92185 22.0391H12.8808ZM16.459 22.0391C16.8728 22.0392 17.2086 22.3752 17.209 22.7891C17.2088 23.2031 16.873 23.539 16.459 23.5391H14.7275C14.3134 23.5391 13.9777 23.2032 13.9775 22.7891C13.9778 22.3753 14.3128 22.0394 14.7265 22.0391H16.459ZM19.3564 22.0391C19.7705 22.0391 20.1071 22.3751 20.1074 22.7891C20.1073 23.2031 19.7714 23.5389 19.3574 23.5391H19.0635C18.6493 23.539 18.3135 23.2032 18.3135 22.7891C18.3137 22.3751 18.6495 22.0392 19.0635 22.0391H19.3564ZM10.1855 0.950222C10.3045 0.626539 10.6635 0.459613 10.9873 0.578152C11.3111 0.697051 11.4779 1.05701 11.3594 1.38089L9.4404 6.60842C11.2712 6.62023 12.534 6.984 13.6426 7.69924C13.9325 7.88633 14.016 8.2735 13.8291 8.5635C13.6419 8.85336 13.2548 8.93714 12.9648 8.75002C12.0834 8.18125 11.0469 7.85792 9.35154 7.85744C8.40737 7.85725 7.83011 8.29872 7.62888 8.71389C7.52952 8.91924 7.5134 9.12534 7.57224 9.31349C7.60658 9.42299 7.67297 9.54419 7.78611 9.66701C7.8081 9.64379 7.82999 9.6196 7.85251 9.59572C7.72688 9.31573 7.82074 8.97928 8.08787 8.81154C9.11751 8.16703 10.4737 8.28506 11.5732 8.68654C12.674 9.08861 13.7516 9.85814 14.2021 10.8174C14.371 11.1772 14.1513 11.6095 13.7685 11.6924C13.7617 11.7923 13.7544 11.8969 13.748 12.0049C14.1962 12.082 14.4968 12.046 14.6826 11.9756C14.8825 11.8997 14.9818 11.7762 15.0293 11.6182C15.1422 11.2404 14.9722 10.5056 14.2744 9.82814C14.0272 9.58772 14.0216 9.19189 14.2617 8.94435C14.5021 8.69712 14.8979 8.6915 15.1455 8.93166C15.3345 9.11519 15.5055 9.31499 15.6552 9.52443L17.8857 6.6133C18.0956 6.33963 18.4878 6.28747 18.7617 6.49709C19.0354 6.70683 19.0881 7.0991 18.8789 7.37307L16.249 10.8028C16.3391 11.2028 16.3391 11.6038 16.2275 11.9766C16.0692 12.5048 15.692 12.9296 15.125 13.1445C14.7132 13.3005 14.2295 13.3348 13.6894 13.2608C13.6825 13.4465 13.6757 13.6333 13.6699 13.8174C13.6519 14.3885 13.6397 14.9235 13.6318 15.3154C13.625 15.654 13.6211 15.8899 13.6201 15.9443C13.6201 16.2235 13.4305 16.4762 13.1601 16.5498C12.7201 16.6232 12.2788 16.6722 11.8427 16.7764C11.1206 16.949 10.2109 17.2517 9.40134 17.7647C7.89664 18.7182 6.97599 19.4365 6.3613 19.9883C5.74196 20.5443 5.43673 20.9247 5.09861 21.2852C4.91911 21.4765 4.6388 21.5354 4.39744 21.4326C4.1565 21.3297 4.00507 21.0869 4.01853 20.8252C4.17895 17.7398 4.63069 14.9266 5.55173 12.1611L5.59568 12.0596C5.61302 12.0278 5.63354 11.9972 5.65623 11.9688C6.10821 11.4021 6.46823 11.0234 6.88767 10.5947C6.89692 10.5853 6.90569 10.575 6.91501 10.5654C6.66279 10.3067 6.48116 10.0099 6.37986 9.68654C6.21786 9.1688 6.2782 8.63513 6.50388 8.16896C6.78976 7.5789 7.32765 7.10847 8.02146 6.84377L10.1855 0.950222ZM11.1445 9.86135C10.5565 9.64663 9.98214 9.56714 9.49802 9.62502C9.47457 9.6687 9.44776 9.71158 9.41306 9.75002C8.68746 10.5525 8.19322 11.0477 7.78122 11.4688C7.39591 11.8626 7.0838 12.1914 6.70212 12.6631C6.27084 13.9767 5.94845 15.307 5.71482 16.6826C6.1386 15.806 6.61502 14.9664 7.22654 14.0108C7.01626 13.3116 7.75399 11.8362 8.61033 11.6953C9.11052 11.6133 9.62805 11.94 9.67185 12.4834C9.72674 13.1687 9.35942 13.6875 8.95505 14.0547C8.71606 14.2717 8.41197 14.4145 8.124 14.4619C7.14963 15.9751 6.53411 17.1724 5.91208 18.7236C6.5649 18.1767 7.45483 17.5176 8.7324 16.708C9.70022 16.0948 10.7551 15.7512 11.5527 15.5606C11.8711 15.4845 12.1537 15.4336 12.3789 15.3975C12.3868 14.9958 12.401 14.4083 12.4209 13.7774C12.4553 12.684 12.512 11.4022 12.6025 10.7354C12.2417 10.3934 11.7319 10.076 11.1445 9.86135Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+22 -16
components/InteractionsPreview.tsx
··· 7 7 import { Popover } from "./Popover"; 8 8 import { TagTiny } from "./Icons/TagTiny"; 9 9 import { SpeedyLink } from "./SpeedyLink"; 10 + import { RecommendButton } from "./RecommendButton"; 10 11 11 12 export const InteractionPreview = (props: { 12 13 quotesCount: number; 13 14 commentsCount: number; 15 + recommendsCount: number; 16 + documentUri: string; 14 17 tags?: string[]; 15 18 postUrl: string; 16 19 showComments: boolean; 17 20 showMentions: boolean; 21 + showRecommends: boolean; 18 22 19 23 share?: boolean; 20 24 }) => { 21 25 let smoker = useSmoker(); 22 26 let interactionsAvailable = 23 27 (props.quotesCount > 0 && props.showMentions) || 24 - (props.showComments !== false && props.commentsCount > 0); 28 + (props.showComments !== false && props.commentsCount > 0) || 29 + (props.showRecommends !== false && props.recommendsCount > 0); 25 30 26 31 const tagsCount = props.tags?.length || 0; 27 32 28 33 return ( 29 - <div 30 - className={`flex gap-2 text-tertiary text-sm items-center self-start`} 31 - > 32 - {tagsCount === 0 ? null : ( 33 - <> 34 - <TagPopover tags={props.tags!} /> 35 - {interactionsAvailable || props.share ? ( 36 - <Separator classname="h-4!" /> 37 - ) : null} 38 - </> 34 + <div className={`flex gap-2 text-tertiary text-sm items-center`}> 35 + {props.showRecommends === false ? null : ( 36 + <RecommendButton 37 + documentUri={props.documentUri} 38 + recommendsCount={props.recommendsCount} 39 + /> 39 40 )} 40 41 41 42 {!props.showMentions || props.quotesCount === 0 ? null : ( ··· 56 57 <CommentTiny /> {props.commentsCount} 57 58 </SpeedyLink> 58 59 )} 59 - {interactionsAvailable && props.share ? ( 60 - <Separator classname="h-4! !min-h-0" /> 61 - ) : null} 60 + {tagsCount === 0 ? null : ( 61 + <> 62 + {interactionsAvailable ? <Separator classname="h-4!" /> : null} 63 + <TagPopover tags={props.tags!} /> 64 + </> 65 + )} 62 66 {props.share && ( 63 67 <> 68 + <Separator classname="h-4!" /> 69 + 64 70 <button 65 71 id={`copy-post-link-${props.postUrl}`} 66 72 className="flex gap-1 items-center hover:text-accent-contrast relative" ··· 71 77 let mouseY = e.clientY; 72 78 73 79 if (!props.postUrl) return; 74 - navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 80 + navigator.clipboard.writeText(props.postUrl); 75 81 76 82 smoker({ 77 83 text: <strong>Copied Link!</strong>, ··· 90 96 ); 91 97 }; 92 98 93 - const TagPopover = (props: { tags: string[] }) => { 99 + export const TagPopover = (props: { tags: string[] }) => { 94 100 return ( 95 101 <Popover 96 102 className="p-2! max-w-xs"
+4 -2
components/LoginButton.tsx
··· 5 5 import { ButtonPrimary } from "./Buttons"; 6 6 import { ActionButton } from "./ActionBar/ActionButton"; 7 7 import { AccountSmall } from "./Icons/AccountSmall"; 8 + import { useIsMobile } from "src/hooks/isMobile"; 8 9 9 10 export function LoginButton() { 10 11 let identityData = useIdentityData(); ··· 26 27 export function LoginActionButton() { 27 28 let identityData = useIdentityData(); 28 29 if (identityData.identity) return null; 30 + let isMobile = useIsMobile(); 29 31 return ( 30 32 <Popover 31 33 asChild 32 - align="start" 33 - side="right" 34 + side={isMobile ? "top" : "right"} 35 + align={isMobile ? "center" : "start"} 34 36 trigger={ 35 37 <ActionButton secondary icon={<AccountSmall />} label="Sign In" /> 36 38 }
+27
components/NavStateTracker.tsx
··· 1 + "use client"; 2 + 3 + import { usePathname } from "next/navigation"; 4 + import { useEffect, useRef } from "react"; 5 + 6 + export function NavStateTracker() { 7 + const pathname = usePathname(); 8 + const lastState = useRef<string | null>(null); 9 + 10 + useEffect(() => { 11 + let state: string | null = null; 12 + if (pathname === "/home") state = "home"; 13 + else if (pathname === "/reader" || pathname.startsWith("/reader/")) 14 + state = "reader"; 15 + 16 + if (state && state !== lastState.current) { 17 + lastState.current = state; 18 + fetch("/api/update-nav-state", { 19 + method: "POST", 20 + headers: { "Content-Type": "application/json" }, 21 + body: JSON.stringify({ state }), 22 + }); 23 + } 24 + }, [pathname]); 25 + 26 + return null; 27 + }
+1 -1
components/PageHeader.tsx
··· 29 29 <div 30 30 className={` 31 31 headerWrapper 32 - sticky top-0 z-10 32 + sticky top-0 z-20 33 33 w-full bg-transparent 34 34 `} 35 35 >
+36 -13
components/PageLayouts/DashboardLayout.tsx
··· 4 4 import { Header } from "../PageHeader"; 5 5 import { Footer } from "components/ActionBar/Footer"; 6 6 import { Sidebar } from "components/ActionBar/Sidebar"; 7 + import { DesktopNavigation } from "components/ActionBar/DesktopNavigation"; 8 + 9 + import { MobileNavigation } from "components/ActionBar/MobileNavigation"; 7 10 import { 8 - DesktopNavigation, 9 - MobileNavigation, 10 11 navPages, 11 12 NotificationButton, 12 - } from "components/ActionBar/Navigation"; 13 + } from "components/ActionBar/NavigationButtons"; 13 14 import { create } from "zustand"; 14 15 import { Popover } from "components/Popover"; 15 16 import { Checkbox } from "components/Checkbox"; ··· 26 27 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 27 28 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 28 29 import { Tab } from "components/Tab"; 30 + import { PubIcon, PublicationButtons } from "components/ActionBar/Publications"; 29 31 30 32 export type DashboardState = { 31 33 display?: "grid" | "list"; ··· 69 71 }, 70 72 })); 71 73 72 - const DashboardIdContext = createContext<string | null>(null); 74 + export const DashboardIdContext = createContext<string | null>(null); 73 75 74 76 export const useDashboardId = () => { 75 77 const id = useContext(DashboardIdContext); ··· 138 140 defaultTab: keyof T; 139 141 currentPage: navPages; 140 142 publication?: string; 141 - actions: React.ReactNode; 143 + profileDid?: string; 144 + actions?: React.ReactNode; 145 + pageTitle?: string; 146 + onTabHover?: (tabName: string) => void; 142 147 }) { 143 148 const searchParams = useSearchParams(); 144 149 const tabParam = searchParams.get("tab"); ··· 165 170 let [headerState, setHeaderState] = useState<"default" | "controls">( 166 171 "default", 167 172 ); 173 + 168 174 return ( 169 175 <DashboardIdContext.Provider value={props.id}> 170 176 <div ··· 184 190 ref={ref} 185 191 id="home-content" 186 192 > 193 + {props.pageTitle && ( 194 + <PageTitle pageTitle={props.pageTitle} actions={props.actions} /> 195 + )} 196 + 187 197 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 188 198 <> 189 199 <Header> ··· 198 208 name={t} 199 209 selected={t === tab} 200 210 onSelect={() => setTabWithUrl(t)} 211 + onMouseEnter={() => props.onTabHover?.(t)} 212 + onPointerDown={() => props.onTabHover?.(t)} 201 213 /> 202 214 ); 203 215 })} ··· 240 252 <Footer> 241 253 <MobileNavigation 242 254 currentPage={props.currentPage} 243 - publication={props.publication} 255 + currentPublicationUri={props.publication} 256 + currentProfileDid={props.profileDid} 244 257 /> 245 - {props.actions && ( 246 - <> 247 - <Separator /> 248 - {props.actions} 249 - </> 250 - )} 251 258 </Footer> 252 259 </div> 253 260 </DashboardIdContext.Provider> 254 261 ); 255 262 } 263 + 264 + export const PageTitle = (props: { 265 + pageTitle: string; 266 + actions: React.ReactNode; 267 + }) => { 268 + return ( 269 + <MediaContents 270 + mobile={true} 271 + className="flex justify-between items-center px-1 mt-1 -mb-1 w-full " 272 + > 273 + <h4 className="grow truncate">{props.pageTitle}</h4> 274 + <div className="flex flex-row-reverse! gap-1">{props.actions}</div> 275 + {/* <div className="shrink-0 h-6">{props.controls}</div> */} 276 + </MediaContents> 277 + ); 278 + }; 256 279 257 280 export const HomeDashboardControls = (props: { 258 281 searchValue: string; ··· 447 470 className={`dashboardSearchInput 448 471 appearance-none! outline-hidden! 449 472 w-full min-w-0 text-primary relative pl-7 pr-1 -my-px 450 - border rounded-md border-transparent focus-within:border-border 473 + border rounded-md border-border-light focus-within:border-border 451 474 bg-transparent ${props.hasBackgroundImage ? "focus-within:bg-bg-page" : "focus-within:bg-bg-leaflet"} `} 452 475 type="text" 453 476 id="pubName"
+21 -7
components/PageSWRDataProvider.tsx
··· 8 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 9 import { createContext, useContext, useMemo } from "react"; 10 10 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 12 12 import { AtUri } from "@atproto/syntax"; 13 13 import { 14 14 normalizeDocumentRecord, ··· 119 119 // Compute the full post URL for sharing 120 120 let postShareLink: string | undefined; 121 121 if (publishedInPublication?.publications && publishedInPublication.documents) { 122 - // Published in a publication - use publication URL + document rkey 123 - const docUri = new AtUri(publishedInPublication.documents.uri); 124 - postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`; 122 + const normalizedDoc = normalizeDocumentRecord( 123 + publishedInPublication.documents.data, 124 + publishedInPublication.documents.uri, 125 + ); 126 + if (normalizedDoc) { 127 + postShareLink = getDocumentURL( 128 + normalizedDoc, 129 + publishedInPublication.documents.uri, 130 + publishedInPublication.publications, 131 + ); 132 + } 125 133 } else if (publishedStandalone?.document) { 126 - // Standalone published post - use /p/{did}/{rkey} format 127 - const docUri = new AtUri(publishedStandalone.document); 128 - postShareLink = `/p/${docUri.host}/${docUri.rkey}`; 134 + const normalizedDoc = publishedStandalone.documents 135 + ? normalizeDocumentRecord(publishedStandalone.documents.data, publishedStandalone.document) 136 + : null; 137 + if (normalizedDoc) { 138 + postShareLink = getDocumentURL(normalizedDoc, publishedStandalone.document); 139 + } else { 140 + const docUri = new AtUri(publishedStandalone.document); 141 + postShareLink = `/p/${docUri.host}/${docUri.rkey}`; 142 + } 129 143 } 130 144 131 145 return {
+54 -25
components/Pages/PublicationMetadata.tsx
··· 20 20 import { useIdentityData } from "components/IdentityProvider"; 21 21 import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 22 22 import { Backdater } from "./Backdater"; 23 + import { RecommendTinyEmpty } from "components/Icons/RecommendTiny"; 24 + import { mergePreferences } from "src/utils/mergePreferences"; 23 25 24 - export const PublicationMetadata = () => { 26 + export const PublicationMetadata = (props: { noInteractions?: boolean }) => { 25 27 let { rep } = useReplicache(); 26 - let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); 28 + let { 29 + data: pub, 30 + normalizedDocument, 31 + normalizedPublication, 32 + } = useLeafletPublicationData(); 27 33 let { identity } = useIdentityData(); 28 34 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 29 35 let description = useSubscribe(rep, (tx) => 30 36 tx.get<string>("publication_description"), 37 + ); 38 + let postPreferences = useSubscribe(rep, (tx) => 39 + tx.get<{ 40 + showComments?: boolean; 41 + showMentions?: boolean; 42 + showRecommends?: boolean; 43 + } | null>("post_preferences"), 44 + ); 45 + let merged = mergePreferences( 46 + postPreferences || undefined, 47 + normalizedPublication?.preferences, 31 48 ); 32 49 let publishedAt = normalizedDocument?.publishedAt; 33 50 ··· 114 131 ) : ( 115 132 <p>Draft</p> 116 133 )} 117 - <div className="flex gap-2 text-border items-center"> 118 - {tags && ( 119 - <> 120 - <AddTags /> 121 - {normalizedPublication?.preferences?.showMentions !== false || 122 - normalizedPublication?.preferences?.showComments !== false ? ( 123 - <Separator classname="h-4!" /> 124 - ) : null} 125 - </> 126 - )} 127 - {normalizedPublication?.preferences?.showMentions !== false && ( 128 - <div className="flex gap-1 items-center"> 129 - <QuoteTiny />— 130 - </div> 131 - )} 132 - {normalizedPublication?.preferences?.showComments !== false && ( 133 - <div className="flex gap-1 items-center"> 134 - <CommentTiny />— 135 - </div> 136 - )} 137 - </div> 134 + {!props.noInteractions && ( 135 + <div className="flex gap-2 text-border items-center"> 136 + {merged.showRecommends !== false && ( 137 + <div className="flex gap-1 items-center"> 138 + <RecommendTinyEmpty />— 139 + </div> 140 + )} 141 + 142 + {merged.showMentions !== false && ( 143 + <div className="flex gap-1 items-center"> 144 + <QuoteTiny />— 145 + </div> 146 + )} 147 + {merged.showComments !== false && ( 148 + <div className="flex gap-1 items-center"> 149 + <CommentTiny />— 150 + </div> 151 + )} 152 + {tags && ( 153 + <> 154 + {merged.showRecommends !== false || 155 + merged.showMentions !== false || 156 + merged.showComments !== false ? ( 157 + <Separator classname="h-4!" /> 158 + ) : null} 159 + <AddTags /> 160 + </> 161 + )} 162 + </div> 163 + )} 138 164 </> 139 165 } 140 166 /> ··· 238 264 ); 239 265 }; 240 266 241 - const AddTags = () => { 267 + export const AddTags = () => { 242 268 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 243 269 let { rep } = useReplicache(); 244 270 ··· 251 277 let tags: string[] = []; 252 278 if (Array.isArray(replicacheTags)) { 253 279 tags = replicacheTags; 254 - } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 280 + } else if ( 281 + normalizedDocument?.tags && 282 + Array.isArray(normalizedDocument.tags) 283 + ) { 255 284 tags = normalizedDocument.tags as string[]; 256 285 } 257 286
+5
components/Pages/useHasBackgroundImage.ts
··· 1 + import { useHasBackgroundImageContext } from "components/ThemeManager/ThemeProvider"; 2 + 3 + export function useHasBackgroundImage(entityID?: string | null) { 4 + return useHasBackgroundImageContext(); 5 + }
+1 -1
components/Popover/index.tsx
··· 42 42 <NestedCardThemeProvider> 43 43 <RadixPopover.Content 44 44 className={` 45 - z-20 bg-bg-page 45 + z-20 relative bg-bg-page 46 46 px-3 py-2 text-primary 47 47 max-w-(--radix-popover-content-available-width) 48 48 max-h-(--radix-popover-content-available-height)
+228 -71
components/PostListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/api"; 3 3 import { PubIcon } from "components/ActionBar/Publications"; 4 - import { CommentTiny } from "components/Icons/CommentTiny"; 5 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 6 - import { Separator } from "components/Layout"; 7 4 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 5 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 - import { useSmoker } from "components/Toast"; 10 6 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 7 import type { 12 8 NormalizedDocument, 13 9 NormalizedPublication, 14 10 } from "src/utils/normalizeRecords"; 11 + import { hasLeafletContent } from "lexicons/src/normalize"; 15 12 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 16 13 17 14 import Link from "next/link"; 18 - import { InteractionPreview } from "./InteractionsPreview"; 15 + import { useEffect, useRef, useState } from "react"; 16 + import { InteractionPreview, TagPopover } from "./InteractionsPreview"; 19 17 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 18 + import { useSmoker } from "./Toast"; 19 + import { Separator } from "./Layout"; 20 + import { CommentTiny } from "./Icons/CommentTiny"; 21 + import { QuoteTiny } from "./Icons/QuoteTiny"; 22 + import { ShareTiny } from "./Icons/ShareTiny"; 23 + import { useSelectedPostListing } from "src/useSelectedPostState"; 24 + import { mergePreferences } from "src/utils/mergePreferences"; 25 + import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 26 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 27 + import { RecommendButton } from "./RecommendButton"; 20 28 21 29 export const PostListing = (props: Post) => { 22 30 let pubRecord = props.publication?.pubRecord as ··· 36 44 let isStandalone = !pubRecord; 37 45 let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 38 46 let themeRecord = pubRecord?.theme || postRecord?.theme; 47 + let elRef = useRef<HTMLDivElement>(null); 48 + let [hasBackgroundImage, setHasBackgroundImage] = useState(false); 49 + 50 + useEffect(() => { 51 + if (!themeRecord?.backgroundImage?.image || !elRef.current) { 52 + setHasBackgroundImage(false); 53 + return; 54 + } 55 + let alpha = Number( 56 + window 57 + .getComputedStyle(elRef.current) 58 + .getPropertyValue("--bg-page-alpha"), 59 + ); 60 + setHasBackgroundImage(alpha < 0.7); 61 + }, [themeRecord?.backgroundImage?.image]); 62 + 39 63 let backgroundImage = 40 64 themeRecord?.backgroundImage?.image?.ref && uri 41 65 ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) ··· 48 72 ? pubRecord?.theme?.showPageBackground 49 73 : postRecord.theme?.showPageBackground ?? true; 50 74 51 - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 75 + let mergedPrefs = mergePreferences( 76 + postRecord?.preferences, 77 + pubRecord?.preferences, 78 + ); 79 + 80 + let quotes = 81 + props.documents.mentionsCount ?? 82 + props.documents.document_mentions_in_bsky?.[0]?.count ?? 83 + 0; 52 84 let comments = 53 - pubRecord?.preferences?.showComments === false 85 + mergedPrefs.showComments === false 54 86 ? 0 55 87 : props.documents.comments_on_documents?.[0]?.count || 0; 88 + let recommends = props.documents.recommends_on_documents?.[0]?.count || 0; 56 89 let tags = (postRecord?.tags as string[] | undefined) || []; 57 90 58 91 // For standalone posts, link directly to the document 59 - let postHref = props.publication 60 - ? `${props.publication.href}/${postUri.rkey}` 61 - : `/p/${postUri.host}/${postUri.rkey}`; 92 + let postUrl = getDocumentURL(postRecord, props.documents.uri, pubRecord); 62 93 63 94 return ( 64 - <BaseThemeProvider {...theme} local> 65 - <div 66 - style={{ 67 - backgroundImage: backgroundImage 68 - ? `url(${backgroundImage})` 69 - : undefined, 70 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 71 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 72 - }} 73 - className={`no-underline! flex flex-row gap-2 w-full relative 74 - bg-bg-leaflet 75 - border border-border-light rounded-lg 76 - sm:p-2 p-2 selected-outline 77 - hover:outline-accent-contrast hover:border-accent-contrast 78 - `} 79 - > 80 - <Link className="h-full w-full absolute top-0 left-0" href={postHref} /> 95 + <div className="postListing flex flex-col gap-1"> 96 + <BaseThemeProvider {...theme} local> 81 97 <div 82 - className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 83 - style={{ 84 - backgroundColor: showPageBackground 85 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 86 - : "transparent", 87 - }} 98 + ref={elRef} 99 + id={`post-listing-${postUri}`} 100 + className={` 101 + relative 102 + flex flex-col overflow-hidden 103 + selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast 104 + hover:border-accent-contrast 105 + ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `} 106 + style={ 107 + hasBackgroundImage 108 + ? { 109 + backgroundImage: backgroundImage 110 + ? `url(${backgroundImage})` 111 + : undefined, 112 + backgroundRepeat: backgroundImageRepeat 113 + ? "repeat" 114 + : "no-repeat", 115 + backgroundSize: backgroundImageRepeat 116 + ? `${backgroundImageSize}px` 117 + : "cover", 118 + } 119 + : {} 120 + } 88 121 > 89 - <h3 className="text-primary truncate">{postRecord.title}</h3> 122 + <Link 123 + className="h-full w-full absolute top-0 left-0" 124 + href={postUrl} 125 + /> 126 + {postRecord.coverImage && ( 127 + <div className="postListingImage"> 128 + <img 129 + src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)} 130 + alt={postRecord.title || ""} 131 + className="w-full h-auto aspect-video object-cover object-top-left rounded" 132 + /> 133 + </div> 134 + )} 135 + <div className="postListingInfo px-3 py-2"> 136 + <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base"> 137 + {postRecord.title} 138 + </h3> 90 139 91 - <p className="text-secondary italic">{postRecord.description}</p> 92 - <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 93 - {props.publication && pubRecord && ( 94 - <PubInfo 95 - href={props.publication.href} 96 - pubRecord={pubRecord} 97 - uri={props.publication.uri} 98 - /> 99 - )} 100 - <div className="flex flex-row justify-between gap-2 items-center w-full"> 101 - <PostInfo publishedAt={postRecord.publishedAt} /> 102 - <InteractionPreview 103 - postUrl={postHref} 104 - quotesCount={quotes} 105 - commentsCount={comments} 106 - tags={tags} 107 - showComments={pubRecord?.preferences?.showComments !== false} 108 - showMentions={pubRecord?.preferences?.showMentions !== false} 109 - share 110 - /> 140 + <p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm"> 141 + {postRecord.description} 142 + </p> 143 + <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full"> 144 + {props.publication && pubRecord && ( 145 + <PubInfo 146 + href={props.publication.href} 147 + pubRecord={pubRecord} 148 + uri={props.publication.uri} 149 + postRecord={postRecord} 150 + /> 151 + )} 152 + <div className="flex flex-row justify-between gap-2 text-xs items-center w-full"> 153 + <PostDate publishedAt={postRecord.publishedAt} /> 154 + {tags.length === 0 ? null : <TagPopover tags={tags!} />} 155 + </div> 111 156 </div> 112 157 </div> 113 158 </div> 159 + </BaseThemeProvider> 160 + <div className="text-sm flex justify-between text-tertiary"> 161 + <Interactions 162 + postUrl={postUrl} 163 + quotesCount={quotes} 164 + commentsCount={comments} 165 + recommendsCount={recommends} 166 + tags={tags} 167 + showComments={mergedPrefs.showComments !== false} 168 + showMentions={mergedPrefs.showMentions !== false} 169 + documentUri={props.documents.uri} 170 + document={postRecord} 171 + publication={pubRecord} 172 + /> 173 + <Share postUrl={postUrl} /> 114 174 </div> 115 - </BaseThemeProvider> 175 + </div> 116 176 ); 117 177 }; 118 178 ··· 120 180 href: string; 121 181 pubRecord: NormalizedPublication; 122 182 uri: string; 183 + postRecord: NormalizedDocument; 123 184 }) => { 185 + let isLeaflet = hasLeafletContent(props.postRecord); 186 + let cleanUrl = props.pubRecord.url 187 + ?.replace(/^https?:\/\//, "") 188 + .replace(/^www\./, ""); 189 + 124 190 return ( 125 - <div className="flex flex-col md:w-auto shrink-0 w-full"> 126 - <hr className="md:hidden block border-border-light mb-2" /> 127 - <Link 128 - href={props.href} 129 - className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0" 130 - > 131 - <PubIcon small record={props.pubRecord} uri={props.uri} /> 132 - {props.pubRecord.name} 133 - </Link> 191 + <div className="flex flex-col shrink-0 w-full"> 192 + <hr className=" block border-border-light mb-1" /> 193 + <div className="flex justify-between gap-4 w-full "> 194 + <Link 195 + href={props.href} 196 + className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0" 197 + > 198 + <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 199 + <div className="w-max min-w-0">{props.pubRecord.name}</div> 200 + </Link> 201 + {!isLeaflet && ( 202 + <div className="text-sm flex flex-row items-center text-tertiary gap-1 min-w-0"> 203 + <div className="truncate min-w-0">{cleanUrl}</div> 204 + <ExternalLinkTiny className="shrink-0" /> 205 + </div> 206 + )} 207 + </div> 134 208 </div> 135 209 ); 136 210 }; 137 211 138 - const PostInfo = (props: { publishedAt: string | undefined }) => { 212 + const PostDate = (props: { publishedAt: string | undefined }) => { 139 213 let localizedDate = useLocalizedDate(props.publishedAt || "", { 140 214 year: "numeric", 141 215 month: "short", 142 216 day: "numeric", 143 217 }); 218 + if (props.publishedAt) { 219 + return <div className="shrink-0 sm:text-sm text-xs">{localizedDate}</div>; 220 + } else return null; 221 + }; 222 + 223 + const Interactions = (props: { 224 + quotesCount: number; 225 + commentsCount: number; 226 + recommendsCount: number; 227 + tags?: string[]; 228 + postUrl: string; 229 + showComments: boolean; 230 + showMentions: boolean; 231 + documentUri: string; 232 + document: NormalizedDocument; 233 + publication?: NormalizedPublication; 234 + }) => { 235 + let setSelectedPostListing = useSelectedPostListing( 236 + (s) => s.setSelectedPostListing, 237 + ); 238 + let selectPostListing = (drawer: "quotes" | "comments") => { 239 + setSelectedPostListing({ 240 + document_uri: props.documentUri, 241 + document: props.document, 242 + publication: props.publication, 243 + drawer, 244 + }); 245 + }; 246 + 144 247 return ( 145 - <div className="flex gap-2 items-center shrink-0 self-start"> 146 - {props.publishedAt && ( 147 - <> 148 - <div className="shrink-0">{localizedDate}</div> 149 - </> 150 - )} 248 + <div 249 + className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} 250 + > 251 + <div className="postListingsInteractions flex gap-3"> 252 + <RecommendButton 253 + documentUri={props.documentUri} 254 + recommendsCount={props.recommendsCount} 255 + /> 256 + {!props.showMentions || props.quotesCount === 0 ? null : ( 257 + <button 258 + aria-label="Post quotes" 259 + onClick={() => selectPostListing("quotes")} 260 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 261 + > 262 + <QuoteTiny /> {props.quotesCount} 263 + </button> 264 + )} 265 + {!props.showComments || props.commentsCount === 0 ? null : ( 266 + <button 267 + aria-label="Post comments" 268 + onClick={() => selectPostListing("comments")} 269 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 270 + > 271 + <CommentTiny /> {props.commentsCount} 272 + </button> 273 + )} 274 + </div> 151 275 </div> 152 276 ); 153 277 }; 278 + 279 + const Share = (props: { postUrl: string }) => { 280 + let smoker = useSmoker(); 281 + return ( 282 + <button 283 + id={`copy-post-link-${props.postUrl}`} 284 + className="flex gap-1 items-center hover:text-accent-contrast relative font-bold" 285 + onClick={(e) => { 286 + e.stopPropagation(); 287 + e.preventDefault(); 288 + let mouseX = e.clientX; 289 + let mouseY = e.clientY; 290 + 291 + if (!props.postUrl) return; 292 + navigator.clipboard.writeText( 293 + props.postUrl.includes("http") 294 + ? props.postUrl 295 + : `leaflet.pub/${props.postUrl}`, 296 + ); 297 + 298 + smoker({ 299 + text: <strong>Copied Link!</strong>, 300 + position: { 301 + y: mouseY, 302 + x: mouseX, 303 + }, 304 + }); 305 + }} 306 + > 307 + Share <ShareTiny /> 308 + </button> 309 + ); 310 + };
+96
components/PostSettings.tsx
··· 1 + "use client"; 2 + 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { SettingsSmall } from "components/Icons/SettingsSmall"; 5 + import { Toggle } from "components/Toggle"; 6 + import { Popover } from "components/Popover"; 7 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 + import { useReplicache } from "src/replicache"; 9 + import { useSubscribe } from "src/replicache/useSubscribe"; 10 + 11 + type PostPreferences = { 12 + showComments?: boolean; 13 + showMentions?: boolean; 14 + showRecommends?: boolean; 15 + }; 16 + 17 + export function PostSettings() { 18 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 19 + let { rep } = useReplicache(); 20 + 21 + let postPreferences = useSubscribe(rep, (tx) => 22 + tx.get<PostPreferences | null>("post_preferences"), 23 + ); 24 + 25 + if (!pub || !pub.publications) return null; 26 + 27 + let pubPrefs = normalizedPublication?.preferences; 28 + 29 + let showComments = 30 + postPreferences?.showComments ?? pubPrefs?.showComments ?? true; 31 + let showMentions = 32 + postPreferences?.showMentions ?? pubPrefs?.showMentions ?? true; 33 + let showRecommends = 34 + postPreferences?.showRecommends ?? pubPrefs?.showRecommends ?? true; 35 + 36 + const updatePreference = ( 37 + field: keyof PostPreferences, 38 + value: boolean, 39 + ) => { 40 + let current: PostPreferences = postPreferences || {}; 41 + rep?.mutate.updatePublicationDraft({ 42 + preferences: { ...current, [field]: value }, 43 + }); 44 + }; 45 + 46 + return ( 47 + <Popover 48 + asChild 49 + side="right" 50 + align="start" 51 + className="max-w-xs w-[1000px]" 52 + trigger={ 53 + <ActionButton 54 + icon={<SettingsSmall />} 55 + label="Settings" 56 + /> 57 + } 58 + > 59 + <div className="text-primary flex flex-col"> 60 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 61 + This Post Settings 62 + </div> 63 + <div className="flex flex-col gap-2"> 64 + <Toggle 65 + toggle={showComments} 66 + onToggle={() => updatePreference("showComments", !showComments)} 67 + > 68 + <div className="font-bold">Show Comments</div> 69 + </Toggle> 70 + <Toggle 71 + toggle={showMentions} 72 + onToggle={() => updatePreference("showMentions", !showMentions)} 73 + > 74 + <div className="flex flex-col justify-start"> 75 + <div className="font-bold">Show Mentions</div> 76 + <div className="text-tertiary text-sm leading-tight"> 77 + Display a list of Bluesky mentions about your post 78 + </div> 79 + </div> 80 + </Toggle> 81 + <Toggle 82 + toggle={showRecommends} 83 + onToggle={() => updatePreference("showRecommends", !showRecommends)} 84 + > 85 + <div className="flex flex-col justify-start"> 86 + <div className="font-bold">Show Recommends</div> 87 + <div className="text-tertiary text-sm leading-tight"> 88 + Allow readers to recommend/like your post 89 + </div> 90 + </div> 91 + </Toggle> 92 + </div> 93 + </div> 94 + </Popover> 95 + ); 96 + }
+173
components/RecommendButton.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import useSWR, { mutate } from "swr"; 5 + import { create, windowScheduler } from "@yornaath/batshit"; 6 + import { RecommendTinyEmpty, RecommendTinyFilled } from "./Icons/RecommendTiny"; 7 + import { 8 + recommendAction, 9 + unrecommendAction, 10 + } from "app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction"; 11 + import { callRPC } from "app/api/rpc/client"; 12 + import { useSmoker, useToaster } from "./Toast"; 13 + import { OAuthErrorMessage, isOAuthSessionError } from "./OAuthError"; 14 + import { ButtonSecondary } from "./Buttons"; 15 + import { Separator } from "./Layout"; 16 + 17 + // Create a batcher for recommendation checks 18 + // Batches requests made within 10ms window 19 + const recommendationBatcher = create({ 20 + fetcher: async (documentUris: string[]) => { 21 + const response = await callRPC("get_user_recommendations", { 22 + documentUris, 23 + }); 24 + return response.result; 25 + }, 26 + resolver: (results, documentUri) => results[documentUri] ?? false, 27 + scheduler: windowScheduler(10), 28 + }); 29 + 30 + const getRecommendationKey = (documentUri: string) => 31 + `recommendation:${documentUri}`; 32 + 33 + function useUserRecommendation(documentUri: string) { 34 + const { data: hasRecommended, isLoading } = useSWR( 35 + getRecommendationKey(documentUri), 36 + () => recommendationBatcher.fetch(documentUri), 37 + ); 38 + 39 + return { 40 + hasRecommended: hasRecommended ?? false, 41 + isLoading, 42 + }; 43 + } 44 + 45 + function mutateRecommendation(documentUri: string, hasRecommended: boolean) { 46 + mutate(getRecommendationKey(documentUri), hasRecommended, { 47 + revalidate: false, 48 + }); 49 + } 50 + 51 + /** 52 + * RecommendButton that fetches the user's recommendation status asynchronously. 53 + * Uses SWR with batched requests for efficient fetching when many buttons are rendered. 54 + */ 55 + export function RecommendButton(props: { 56 + documentUri: string; 57 + recommendsCount: number; 58 + className?: string; 59 + expanded?: boolean; 60 + }) { 61 + const { hasRecommended, isLoading } = useUserRecommendation( 62 + props.documentUri, 63 + ); 64 + const [count, setCount] = useState(props.recommendsCount); 65 + const [isPending, setIsPending] = useState(false); 66 + const [optimisticRecommended, setOptimisticRecommended] = useState< 67 + boolean | null 68 + >(null); 69 + const toaster = useToaster(); 70 + const smoker = useSmoker(); 71 + 72 + // Use optimistic state if set, otherwise use fetched state 73 + const displayRecommended = 74 + optimisticRecommended !== null ? optimisticRecommended : hasRecommended; 75 + 76 + const handleClick = async (e: React.MouseEvent) => { 77 + if (isPending || isLoading) return; 78 + 79 + const currentlyRecommended = displayRecommended; 80 + setIsPending(true); 81 + setOptimisticRecommended(!currentlyRecommended); 82 + setCount((c) => (currentlyRecommended ? c - 1 : c + 1)); 83 + 84 + if (!currentlyRecommended) { 85 + smoker({ 86 + position: { 87 + x: e.clientX, 88 + y: e.clientY - 16, 89 + }, 90 + text: <div className="text-xs">Recc'd!</div>, 91 + }); 92 + } 93 + 94 + const result = currentlyRecommended 95 + ? await unrecommendAction({ document: props.documentUri }) 96 + : await recommendAction({ document: props.documentUri }); 97 + if (!result.success) { 98 + // Revert optimistic update 99 + setOptimisticRecommended(null); 100 + setCount((c) => (currentlyRecommended ? c + 1 : c - 1)); 101 + setIsPending(false); 102 + 103 + toaster({ 104 + content: isOAuthSessionError(result.error) ? ( 105 + <OAuthErrorMessage error={result.error} /> 106 + ) : ( 107 + "oh no! error!" 108 + ), 109 + type: "error", 110 + }); 111 + return; 112 + } 113 + 114 + // Update the SWR cache to match the new state 115 + mutateRecommendation(props.documentUri, !currentlyRecommended); 116 + setOptimisticRecommended(null); 117 + setIsPending(false); 118 + }; 119 + 120 + if (props.expanded) 121 + return ( 122 + <ButtonSecondary 123 + onClick={(e) => { 124 + e.preventDefault(); 125 + e.stopPropagation(); 126 + handleClick(e); 127 + }} 128 + > 129 + {displayRecommended ? ( 130 + <RecommendTinyFilled className="text-accent-contrast" /> 131 + ) : ( 132 + <RecommendTinyEmpty /> 133 + )} 134 + <div className="flex gap-2 items-center"> 135 + {count > 0 && ( 136 + <> 137 + <span 138 + className={`${displayRecommended && "text-accent-contrast"}`} 139 + > 140 + {count} 141 + </span> 142 + <Separator classname="h-4! text-accent-contrast!" /> 143 + </> 144 + )} 145 + {displayRecommended ? "Recommended!" : "Recommend"} 146 + </div> 147 + </ButtonSecondary> 148 + ); 149 + 150 + return ( 151 + <button 152 + onClick={(e) => { 153 + e.preventDefault(); 154 + e.stopPropagation(); 155 + handleClick(e); 156 + }} 157 + disabled={isPending || isLoading} 158 + className={`recommendButton relative flex gap-1 items-center hover:text-accent-contrast ${props.className || ""}`} 159 + aria-label={displayRecommended ? "Remove recommend" : "Recommend"} 160 + > 161 + {displayRecommended ? ( 162 + <RecommendTinyFilled className="text-accent-contrast" /> 163 + ) : ( 164 + <RecommendTinyEmpty /> 165 + )} 166 + {count > 0 && ( 167 + <span className={`${displayRecommended && "text-accent-contrast"}`}> 168 + {count} 169 + </span> 170 + )} 171 + </button> 172 + ); 173 + }
+4
components/Tab.tsx
··· 4 4 name: string; 5 5 selected: boolean; 6 6 onSelect: () => void; 7 + onMouseEnter?: () => void; 8 + onPointerDown?: () => void; 7 9 href?: string; 8 10 }) => { 9 11 return ( 10 12 <div 11 13 className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 12 14 onClick={() => props.onSelect()} 15 + onMouseEnter={props.onMouseEnter} 16 + onPointerDown={props.onPointerDown} 13 17 > 14 18 {props.name} 15 19 {props.href && <ExternalLinkTiny />}
+25 -16
components/ThemeManager/ThemeProvider.tsx
··· 8 8 export function useCardBorderHiddenContext() { 9 9 return useContext(CardBorderHiddenContext); 10 10 } 11 + 12 + // Context for hasBackgroundImage 13 + export const HasBackgroundImageContext = createContext<boolean>(false); 14 + 15 + export function useHasBackgroundImageContext() { 16 + return useContext(HasBackgroundImageContext); 17 + } 11 18 import { 12 19 colorToString, 13 20 useColorAttribute, ··· 79 86 80 87 return ( 81 88 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 82 - <BaseThemeProvider 83 - local={props.local} 84 - bgLeaflet={bgLeaflet} 85 - bgPage={bgPage} 86 - primary={primary} 87 - highlight2={highlight2} 88 - highlight3={highlight3} 89 - highlight1={highlight1?.data.value} 90 - accent1={accent1} 91 - accent2={accent2} 92 - showPageBackground={showPageBackground} 93 - pageWidth={pageWidth?.data.value} 94 - hasBackgroundImage={hasBackgroundImage} 95 - > 96 - {props.children} 97 - </BaseThemeProvider> 89 + <HasBackgroundImageContext.Provider value={hasBackgroundImage}> 90 + <BaseThemeProvider 91 + local={props.local} 92 + bgLeaflet={bgLeaflet} 93 + bgPage={bgPage} 94 + primary={primary} 95 + highlight2={highlight2} 96 + highlight3={highlight3} 97 + highlight1={highlight1?.data.value} 98 + accent1={accent1} 99 + accent2={accent2} 100 + showPageBackground={showPageBackground} 101 + pageWidth={pageWidth?.data.value} 102 + hasBackgroundImage={hasBackgroundImage} 103 + > 104 + {props.children} 105 + </BaseThemeProvider> 106 + </HasBackgroundImageContext.Provider> 98 107 </CardBorderHiddenContext.Provider> 99 108 ); 100 109 }
+1
contexts/DocumentContext.tsx
··· 21 21 | "comments" 22 22 | "mentions" 23 23 | "leafletId" 24 + | "recommendsCount" 24 25 >; 25 26 26 27 const DocumentContext = createContext<DocumentContextValue | null>(null);
+2 -1
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 1 + import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey, integer } from "drizzle-orm/pg-core" 2 2 import { sql } from "drizzle-orm" 3 3 4 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 225 225 uri: text("uri").primaryKey().notNull(), 226 226 data: jsonb("data").notNull(), 227 227 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 228 + bsky_like_count: integer("bsky_like_count").default(0).notNull(), 228 229 }); 229 230 230 231 export const atp_poll_votes = pgTable("atp_poll_votes", {
+1
feeds/index.ts
··· 115 115 ); 116 116 } 117 117 query = query 118 + .eq("indexed", true) 118 119 .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null") 119 120 .order("sort_date", { ascending: false }) 120 121 .order("uri", { ascending: false })
+20
lexicons/api/lexicons.ts
··· 1048 1048 type: 'ref', 1049 1049 ref: 'lex:com.atproto.repo.strongRef', 1050 1050 }, 1051 + clientHost: { 1052 + type: 'string', 1053 + }, 1051 1054 }, 1052 1055 }, 1053 1056 }, ··· 1469 1472 type: 'ref', 1470 1473 ref: 'lex:pub.leaflet.publication#theme', 1471 1474 }, 1475 + preferences: { 1476 + type: 'ref', 1477 + ref: 'lex:pub.leaflet.publication#preferences', 1478 + }, 1472 1479 tags: { 1473 1480 type: 'array', 1474 1481 items: { ··· 1868 1875 type: 'boolean', 1869 1876 default: true, 1870 1877 }, 1878 + showRecommends: { 1879 + type: 'boolean', 1880 + default: true, 1881 + }, 1871 1882 }, 1872 1883 }, 1873 1884 theme: { ··· 2194 2205 maxLength: 5000, 2195 2206 type: 'string', 2196 2207 }, 2208 + preferences: { 2209 + type: 'union', 2210 + refs: ['lex:pub.leaflet.publication#preferences'], 2211 + closed: false, 2212 + }, 2197 2213 updatedAt: { 2198 2214 format: 'datetime', 2199 2215 type: 'string', ··· 2288 2304 }, 2289 2305 showPrevNext: { 2290 2306 default: false, 2307 + type: 'boolean', 2308 + }, 2309 + showRecommends: { 2310 + default: true, 2291 2311 type: 'boolean', 2292 2312 }, 2293 2313 },
+1
lexicons/api/types/pub/leaflet/blocks/bskyPost.ts
··· 18 18 export interface Main { 19 19 $type?: 'pub.leaflet.blocks.bskyPost' 20 20 postRef: ComAtprotoRepoStrongRef.Main 21 + clientHost?: string 21 22 } 22 23 23 24 const hashMain = 'main'
+1
lexicons/api/types/pub/leaflet/document.ts
··· 23 23 publication?: string 24 24 author: string 25 25 theme?: PubLeafletPublication.Theme 26 + preferences?: PubLeafletPublication.Preferences 26 27 tags?: string[] 27 28 coverImage?: BlobRef 28 29 pages: (
+1
lexicons/api/types/pub/leaflet/publication.ts
··· 39 39 showComments: boolean 40 40 showMentions: boolean 41 41 showPrevNext: boolean 42 + showRecommends: boolean 42 43 } 43 44 44 45 const hashPreferences = 'preferences'
+1
lexicons/api/types/site/standard/document.ts
··· 28 28 textContent?: string 29 29 theme?: PubLeafletPublication.Theme 30 30 title: string 31 + preferences?: $Typed<PubLeafletPublication.Preferences> | { $type: string } 31 32 updatedAt?: string 32 33 [k: string]: unknown 33 34 }
+1
lexicons/api/types/site/standard/publication.ts
··· 40 40 showComments: boolean 41 41 showMentions: boolean 42 42 showPrevNext: boolean 43 + showRecommends: boolean 43 44 } 44 45 45 46 const hashPreferences = 'preferences'
+3
lexicons/pub/leaflet/blocks/bskyPost.json
··· 11 11 "postRef": { 12 12 "type": "ref", 13 13 "ref": "com.atproto.repo.strongRef" 14 + }, 15 + "clientHost": { 16 + "type": "string" 14 17 } 15 18 } 16 19 }
+4
lexicons/pub/leaflet/document.json
··· 46 46 "type": "ref", 47 47 "ref": "pub.leaflet.publication#theme" 48 48 }, 49 + "preferences": { 50 + "type": "ref", 51 + "ref": "pub.leaflet.publication#preferences" 52 + }, 49 53 "tags": { 50 54 "type": "array", 51 55 "items": {
+4
lexicons/pub/leaflet/publication.json
··· 59 59 "showPrevNext": { 60 60 "type": "boolean", 61 61 "default": true 62 + }, 63 + "showRecommends": { 64 + "type": "boolean", 65 + "default": true 62 66 } 63 67 } 64 68 },
+5
lexicons/site/standard/document.json
··· 57 57 "maxLength": 5000, 58 58 "type": "string" 59 59 }, 60 + "preferences": { 61 + "type": "union", 62 + "refs": ["pub.leaflet.publication#preferences"], 63 + "closed": false 64 + }, 60 65 "updatedAt": { 61 66 "format": "datetime", 62 67 "type": "string"
+4
lexicons/site/standard/publication.json
··· 58 58 "showPrevNext": { 59 59 "default": false, 60 60 "type": "boolean" 61 + }, 62 + "showRecommends": { 63 + "default": true, 64 + "type": "boolean" 61 65 } 62 66 }, 63 67 "type": "object"
+1
lexicons/src/blocks.ts
··· 43 43 required: ["postRef"], 44 44 properties: { 45 45 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 46 + clientHost: { type: "string" }, 46 47 }, 47 48 }, 48 49 },
+4
lexicons/src/document.ts
··· 23 23 publication: { type: "string", format: "at-uri" }, 24 24 author: { type: "string", format: "at-identifier" }, 25 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + preferences: { 27 + type: "ref", 28 + ref: "pub.leaflet.publication#preferences", 29 + }, 26 30 tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 31 coverImage: { 28 32 type: "blob",
+28 -11
lexicons/src/normalize.ts
··· 28 28 export type NormalizedDocument = SiteStandardDocument.Record & { 29 29 // Keep the original theme for components that need leaflet-specific styling 30 30 theme?: PubLeafletPublication.Theme; 31 + preferences?: SiteStandardPublication.Preferences; 31 32 }; 32 33 33 34 // Normalized publication type - uses the generated site.standard.publication type ··· 50 51 * Checks if the record is a pub.leaflet.document 51 52 */ 52 53 export function isLeafletDocument( 53 - record: unknown 54 + record: unknown, 54 55 ): record is PubLeafletDocument.Record { 55 56 if (!record || typeof record !== "object") return false; 56 57 const r = record as Record<string, unknown>; ··· 65 66 * Checks if the record is a site.standard.document 66 67 */ 67 68 export function isStandardDocument( 68 - record: unknown 69 + record: unknown, 69 70 ): record is SiteStandardDocument.Record { 70 71 if (!record || typeof record !== "object") return false; 71 72 const r = record as Record<string, unknown>; ··· 76 77 * Checks if the record is a pub.leaflet.publication 77 78 */ 78 79 export function isLeafletPublication( 79 - record: unknown 80 + record: unknown, 80 81 ): record is PubLeafletPublication.Record { 81 82 if (!record || typeof record !== "object") return false; 82 83 const r = record as Record<string, unknown>; ··· 91 92 * Checks if the record is a site.standard.publication 92 93 */ 93 94 export function isStandardPublication( 94 - record: unknown 95 + record: unknown, 95 96 ): record is SiteStandardPublication.Record { 96 97 if (!record || typeof record !== "object") return false; 97 98 const r = record as Record<string, unknown>; ··· 106 107 | $Typed<PubLeafletThemeColor.Rgba> 107 108 | $Typed<PubLeafletThemeColor.Rgb> 108 109 | { $type: string } 109 - | undefined 110 + | undefined, 110 111 ): { r: number; g: number; b: number } | undefined { 111 112 if (!color || typeof color !== "object") return undefined; 112 113 const c = color as Record<string, unknown>; ··· 124 125 * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 126 */ 126 127 export function leafletThemeToBasicTheme( 127 - theme: PubLeafletPublication.Theme | undefined 128 + theme: PubLeafletPublication.Theme | undefined, 128 129 ): SiteStandardThemeBasic.Main | undefined { 129 130 if (!theme) return undefined; 130 131 131 132 const background = extractRgb(theme.backgroundColor); 132 - const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 + const accent = 134 + extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 135 const accentForeground = extractRgb(theme.accentText); 134 136 135 137 // If we don't have the required colors, return undefined ··· 160 162 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 161 163 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 162 164 */ 163 - export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 165 + export function normalizeDocument( 166 + record: unknown, 167 + uri?: string, 168 + ): NormalizedDocument | null { 164 169 if (!record || typeof record !== "object") return null; 165 170 166 171 // Pass through site.standard records directly (theme is already in correct format if present) 167 172 if (isStandardDocument(record)) { 173 + const preferences = record.preferences as 174 + | SiteStandardPublication.Preferences 175 + | undefined; 168 176 return { 169 177 ...record, 170 178 theme: record.theme, 179 + preferences, 171 180 } as NormalizedDocument; 172 181 } 173 182 ··· 194 203 } 195 204 : undefined; 196 205 206 + // Extract preferences if present (available after lexicon rebuild) 207 + const leafletPrefs = (record as Record<string, unknown>) 208 + .preferences as SiteStandardPublication.Preferences | undefined; 209 + 197 210 return { 198 211 $type: "site.standard.document", 199 212 title: record.title, ··· 206 219 bskyPostRef: record.postRef, 207 220 content, 208 221 theme: record.theme, 222 + preferences: leafletPrefs 223 + ? { ...leafletPrefs, $type: "site.standard.publication#preferences" as const } 224 + : undefined, 209 225 }; 210 226 } 211 227 ··· 219 235 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 220 236 */ 221 237 export function normalizePublication( 222 - record: unknown 238 + record: unknown, 223 239 ): NormalizedPublication | null { 224 240 if (!record || typeof record !== "object") return null; 225 241 ··· 268 284 showComments: record.preferences.showComments, 269 285 showMentions: record.preferences.showMentions, 270 286 showPrevNext: record.preferences.showPrevNext, 287 + showRecommends: record.preferences.showRecommends, 271 288 } 272 289 : undefined; 273 290 ··· 290 307 * Type guard to check if a normalized document has leaflet content 291 308 */ 292 309 export function hasLeafletContent( 293 - doc: NormalizedDocument 310 + doc: NormalizedDocument, 294 311 ): doc is NormalizedDocument & { 295 312 content: $Typed<PubLeafletContent.Main>; 296 313 } { ··· 304 321 * Gets the pages array from a normalized document, handling both formats 305 322 */ 306 323 export function getDocumentPages( 307 - doc: NormalizedDocument 324 + doc: NormalizedDocument, 308 325 ): PubLeafletContent.Main["pages"] | undefined { 309 326 if (!doc.content) return undefined; 310 327
+1
lexicons/src/publication.ts
··· 29 29 showComments: { type: "boolean", default: true }, 30 30 showMentions: { type: "boolean", default: true }, 31 31 showPrevNext: { type: "boolean", default: true }, 32 + showRecommends: { type: "boolean", default: true }, 32 33 }, 33 34 }, 34 35 theme: {
+423 -681
package-lock.json
··· 12 12 "@atproto/api": "^0.16.9", 13 13 "@atproto/common": "^0.4.8", 14 14 "@atproto/identity": "^0.4.6", 15 - "@atproto/lexicon": "^0.5.1", 15 + "@atproto/lexicon": "^0.6.1", 16 16 "@atproto/oauth-client-node": "^0.3.8", 17 17 "@atproto/sync": "^0.1.34", 18 18 "@atproto/syntax": "^0.3.3", 19 - "@atproto/tap": "^0.1.1", 20 19 "@atproto/xrpc": "^0.7.5", 21 20 "@atproto/xrpc-server": "^0.9.5", 22 21 "@hono/node-server": "^1.14.3", ··· 38 37 "@vercel/analytics": "^1.5.0", 39 38 "@vercel/functions": "^2.2.12", 40 39 "@vercel/sdk": "^1.11.4", 40 + "@yornaath/batshit": "^0.14.0", 41 41 "babel-plugin-react-compiler": "^19.1.0-rc.1", 42 42 "base64-js": "^1.5.1", 43 43 "colorjs.io": "^0.5.2", ··· 237 237 } 238 238 }, 239 239 "node_modules/@atproto/api/node_modules/@atproto/syntax": { 240 - "version": "0.4.1", 241 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", 242 - "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==", 243 - "license": "MIT" 240 + "version": "0.4.3", 241 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 242 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 243 + "license": "MIT", 244 + "dependencies": { 245 + "tslib": "^2.8.1" 246 + } 244 247 }, 245 248 "node_modules/@atproto/api/node_modules/multiformats": { 246 249 "version": "9.9.0", ··· 266 269 } 267 270 }, 268 271 "node_modules/@atproto/common-web": { 269 - "version": "0.4.10", 270 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.10.tgz", 271 - "integrity": "sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw==", 272 + "version": "0.4.15", 273 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.15.tgz", 274 + "integrity": "sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==", 272 275 "license": "MIT", 273 276 "dependencies": { 274 - "@atproto/lex-data": "0.0.6", 275 - "@atproto/lex-json": "0.0.6", 277 + "@atproto/lex-data": "^0.0.10", 278 + "@atproto/lex-json": "^0.0.10", 279 + "@atproto/syntax": "^0.4.3", 276 280 "zod": "^3.23.8" 277 281 } 282 + }, 283 + "node_modules/@atproto/common-web/node_modules/@atproto/lex-data": { 284 + "version": "0.0.10", 285 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz", 286 + "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==", 287 + "license": "MIT", 288 + "dependencies": { 289 + "multiformats": "^9.9.0", 290 + "tslib": "^2.8.1", 291 + "uint8arrays": "3.0.0", 292 + "unicode-segmenter": "^0.14.0" 293 + } 294 + }, 295 + "node_modules/@atproto/common-web/node_modules/@atproto/lex-json": { 296 + "version": "0.0.10", 297 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.10.tgz", 298 + "integrity": "sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==", 299 + "license": "MIT", 300 + "dependencies": { 301 + "@atproto/lex-data": "^0.0.10", 302 + "tslib": "^2.8.1" 303 + } 304 + }, 305 + "node_modules/@atproto/common-web/node_modules/@atproto/syntax": { 306 + "version": "0.4.3", 307 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 308 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 309 + "license": "MIT", 310 + "dependencies": { 311 + "tslib": "^2.8.1" 312 + } 313 + }, 314 + "node_modules/@atproto/common-web/node_modules/multiformats": { 315 + "version": "9.9.0", 316 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 317 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 318 + "license": "(Apache-2.0 AND MIT)" 278 319 }, 279 320 "node_modules/@atproto/common/node_modules/multiformats": { 280 321 "version": "9.9.0", ··· 355 396 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 356 397 "license": "(Apache-2.0 AND MIT)" 357 398 }, 358 - "node_modules/@atproto/lex": { 359 - "version": "0.0.9", 360 - "resolved": "https://registry.npmjs.org/@atproto/lex/-/lex-0.0.9.tgz", 361 - "integrity": "sha512-o6gauf1lz0iyzJR0rqSj4VHOrO+Nt8+/iPb0KPojw1ieXk13zOSTSxotAoDzO/dP6y8Ey5jxwuCQGuzab/4XnQ==", 362 - "license": "MIT", 363 - "dependencies": { 364 - "@atproto/lex-builder": "0.0.9", 365 - "@atproto/lex-client": "0.0.7", 366 - "@atproto/lex-data": "0.0.6", 367 - "@atproto/lex-installer": "0.0.9", 368 - "@atproto/lex-json": "0.0.6", 369 - "@atproto/lex-schema": "0.0.7", 370 - "tslib": "^2.8.1", 371 - "yargs": "^17.0.0" 372 - }, 373 - "bin": { 374 - "lex": "bin/lex", 375 - "ts-lex": "bin/lex" 376 - } 377 - }, 378 - "node_modules/@atproto/lex-builder": { 379 - "version": "0.0.9", 380 - "resolved": "https://registry.npmjs.org/@atproto/lex-builder/-/lex-builder-0.0.9.tgz", 381 - "integrity": "sha512-buOFk1JpuW3twI7To7f/67zQQ1NulLHf/oasH/kTOPUAd0dNyeAa13t9eRSVGbwi0BcZYxRxBm0QzPmdLKyuyw==", 382 - "license": "MIT", 383 - "dependencies": { 384 - "@atproto/lex-document": "0.0.8", 385 - "@atproto/lex-schema": "0.0.7", 386 - "prettier": "^3.2.5", 387 - "ts-morph": "^27.0.0", 388 - "tslib": "^2.8.1" 389 - } 390 - }, 391 - "node_modules/@atproto/lex-builder/node_modules/@ts-morph/common": { 392 - "version": "0.28.1", 393 - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", 394 - "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", 395 - "license": "MIT", 396 - "dependencies": { 397 - "minimatch": "^10.0.1", 398 - "path-browserify": "^1.0.1", 399 - "tinyglobby": "^0.2.14" 400 - } 401 - }, 402 - "node_modules/@atproto/lex-builder/node_modules/minimatch": { 403 - "version": "10.1.1", 404 - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", 405 - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", 406 - "license": "BlueOak-1.0.0", 407 - "dependencies": { 408 - "@isaacs/brace-expansion": "^5.0.0" 409 - }, 410 - "engines": { 411 - "node": "20 || >=22" 412 - }, 413 - "funding": { 414 - "url": "https://github.com/sponsors/isaacs" 415 - } 416 - }, 417 - "node_modules/@atproto/lex-builder/node_modules/ts-morph": { 418 - "version": "27.0.2", 419 - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", 420 - "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", 421 - "license": "MIT", 422 - "dependencies": { 423 - "@ts-morph/common": "~0.28.1", 424 - "code-block-writer": "^13.0.3" 425 - } 426 - }, 427 - "node_modules/@atproto/lex-cbor": { 428 - "version": "0.0.6", 429 - "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.6.tgz", 430 - "integrity": "sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==", 431 - "license": "MIT", 432 - "dependencies": { 433 - "@atproto/lex-data": "0.0.6", 434 - "multiformats": "^9.9.0", 435 - "tslib": "^2.8.1" 436 - } 437 - }, 438 - "node_modules/@atproto/lex-cbor/node_modules/multiformats": { 439 - "version": "9.9.0", 440 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 441 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 442 - "license": "(Apache-2.0 AND MIT)" 443 - }, 444 399 "node_modules/@atproto/lex-cli": { 445 400 "version": "0.9.5", 446 401 "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.5.tgz", ··· 465 420 } 466 421 }, 467 422 "node_modules/@atproto/lex-cli/node_modules/@atproto/syntax": { 468 - "version": "0.4.1", 469 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", 470 - "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==", 423 + "version": "0.4.3", 424 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 425 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 471 426 "dev": true, 472 - "license": "MIT" 473 - }, 474 - "node_modules/@atproto/lex-client": { 475 - "version": "0.0.7", 476 - "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.7.tgz", 477 - "integrity": "sha512-ofUz3yXJ0nN/M9aqqF2ZUL/4D1wWT1P4popCfV3OEDsDrtWofMflYPFz1IWuyPa2e83paaEHRhaw3bZEhgXH1w==", 478 427 "license": "MIT", 479 428 "dependencies": { 480 - "@atproto/lex-data": "0.0.6", 481 - "@atproto/lex-json": "0.0.6", 482 - "@atproto/lex-schema": "0.0.7", 483 429 "tslib": "^2.8.1" 484 430 } 485 431 }, 486 - "node_modules/@atproto/lex-data": { 487 - "version": "0.0.6", 488 - "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.6.tgz", 489 - "integrity": "sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==", 432 + "node_modules/@atproto/lexicon": { 433 + "version": "0.6.1", 434 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz", 435 + "integrity": "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==", 490 436 "license": "MIT", 491 437 "dependencies": { 492 - "@atproto/syntax": "0.4.2", 438 + "@atproto/common-web": "^0.4.13", 439 + "@atproto/syntax": "^0.4.3", 440 + "iso-datestring-validator": "^2.2.2", 493 441 "multiformats": "^9.9.0", 494 - "tslib": "^2.8.1", 495 - "uint8arrays": "3.0.0", 496 - "unicode-segmenter": "^0.14.0" 497 - } 498 - }, 499 - "node_modules/@atproto/lex-data/node_modules/@atproto/syntax": { 500 - "version": "0.4.2", 501 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 502 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 503 - "license": "MIT" 504 - }, 505 - "node_modules/@atproto/lex-data/node_modules/multiformats": { 506 - "version": "9.9.0", 507 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 508 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 509 - "license": "(Apache-2.0 AND MIT)" 510 - }, 511 - "node_modules/@atproto/lex-document": { 512 - "version": "0.0.8", 513 - "resolved": "https://registry.npmjs.org/@atproto/lex-document/-/lex-document-0.0.8.tgz", 514 - "integrity": "sha512-p3l5h96Hx0vxUwbO/eas6x5h2vU0JVN1a/ktX4k3PlK9YLXfWMFsv+RdVwVZom8o0irHwlcyh1D/cY0PyUojDA==", 515 - "license": "MIT", 516 - "dependencies": { 517 - "@atproto/lex-schema": "0.0.7", 518 - "core-js": "^3", 519 - "tslib": "^2.8.1" 520 - } 521 - }, 522 - "node_modules/@atproto/lex-installer": { 523 - "version": "0.0.9", 524 - "resolved": "https://registry.npmjs.org/@atproto/lex-installer/-/lex-installer-0.0.9.tgz", 525 - "integrity": "sha512-zEeIeSaSCb3j+zNsqqMY7+X5FO6fxy/MafaCEj42KsXQHNcobuygZsnG/0fxMj/kMvhjrNUCp/w9PyOMwx4hQg==", 526 - "license": "MIT", 527 - "dependencies": { 528 - "@atproto/lex-builder": "0.0.9", 529 - "@atproto/lex-cbor": "0.0.6", 530 - "@atproto/lex-data": "0.0.6", 531 - "@atproto/lex-document": "0.0.8", 532 - "@atproto/lex-resolver": "0.0.8", 533 - "@atproto/lex-schema": "0.0.7", 534 - "@atproto/syntax": "0.4.2", 535 - "tslib": "^2.8.1" 536 - } 537 - }, 538 - "node_modules/@atproto/lex-installer/node_modules/@atproto/syntax": { 539 - "version": "0.4.2", 540 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 541 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 542 - "license": "MIT" 543 - }, 544 - "node_modules/@atproto/lex-json": { 545 - "version": "0.0.6", 546 - "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.6.tgz", 547 - "integrity": "sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg==", 548 - "license": "MIT", 549 - "dependencies": { 550 - "@atproto/lex-data": "0.0.6", 551 - "tslib": "^2.8.1" 552 - } 553 - }, 554 - "node_modules/@atproto/lex-resolver": { 555 - "version": "0.0.8", 556 - "resolved": "https://registry.npmjs.org/@atproto/lex-resolver/-/lex-resolver-0.0.8.tgz", 557 - "integrity": "sha512-4hXT560+k5BIttouuhXOr+UkhAuFvvkJaVdqYb8vx2Ez7eHPiZ+yWkUK6FKpyGsx2whHkJzgleEA6DNWtdDlWA==", 558 - "license": "MIT", 559 - "dependencies": { 560 - "@atproto-labs/did-resolver": "0.2.5", 561 - "@atproto/crypto": "0.4.5", 562 - "@atproto/lex-client": "0.0.7", 563 - "@atproto/lex-data": "0.0.6", 564 - "@atproto/lex-document": "0.0.8", 565 - "@atproto/lex-schema": "0.0.7", 566 - "@atproto/repo": "0.8.12", 567 - "@atproto/syntax": "0.4.2", 568 - "tslib": "^2.8.1" 569 - } 570 - }, 571 - "node_modules/@atproto/lex-resolver/node_modules/@atproto-labs/did-resolver": { 572 - "version": "0.2.5", 573 - "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", 574 - "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", 575 - "license": "MIT", 576 - "dependencies": { 577 - "@atproto-labs/fetch": "0.2.3", 578 - "@atproto-labs/pipe": "0.1.1", 579 - "@atproto-labs/simple-store": "0.3.0", 580 - "@atproto-labs/simple-store-memory": "0.1.4", 581 - "@atproto/did": "0.2.4", 582 442 "zod": "^3.23.8" 583 443 } 584 444 }, 585 - "node_modules/@atproto/lex-resolver/node_modules/@atproto/did": { 586 - "version": "0.2.4", 587 - "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.4.tgz", 588 - "integrity": "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==", 589 - "license": "MIT", 590 - "dependencies": { 591 - "zod": "^3.23.8" 592 - } 593 - }, 594 - "node_modules/@atproto/lex-resolver/node_modules/@atproto/syntax": { 595 - "version": "0.4.2", 596 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 597 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 598 - "license": "MIT" 599 - }, 600 - "node_modules/@atproto/lex-schema": { 601 - "version": "0.0.7", 602 - "resolved": "https://registry.npmjs.org/@atproto/lex-schema/-/lex-schema-0.0.7.tgz", 603 - "integrity": "sha512-/7HkTUsnP1rlzmVE6nnY0kl/hydL/W8V29V8BhFwdAvdDKpYcdRgzzsMe38LAt+ZOjHknRCZDIKGsbQMSbJErw==", 445 + "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 446 + "version": "0.4.3", 447 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 448 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 604 449 "license": "MIT", 605 450 "dependencies": { 606 - "@atproto/lex-data": "0.0.6", 607 - "@atproto/syntax": "0.4.2", 608 451 "tslib": "^2.8.1" 609 452 } 610 - }, 611 - "node_modules/@atproto/lex-schema/node_modules/@atproto/syntax": { 612 - "version": "0.4.2", 613 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 614 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 615 - "license": "MIT" 616 - }, 617 - "node_modules/@atproto/lexicon": { 618 - "version": "0.5.1", 619 - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz", 620 - "integrity": "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==", 621 - "license": "MIT", 622 - "dependencies": { 623 - "@atproto/common-web": "^0.4.3", 624 - "@atproto/syntax": "^0.4.1", 625 - "iso-datestring-validator": "^2.2.2", 626 - "multiformats": "^9.9.0", 627 - "zod": "^3.23.8" 628 - } 629 - }, 630 - "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 631 - "version": "0.4.1", 632 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", 633 - "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==", 634 - "license": "MIT" 635 453 }, 636 454 "node_modules/@atproto/lexicon/node_modules/multiformats": { 637 455 "version": "9.9.0", ··· 716 534 } 717 535 }, 718 536 "node_modules/@atproto/repo/node_modules/@atproto/common": { 719 - "version": "0.5.6", 720 - "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 721 - "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 537 + "version": "0.5.10", 538 + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz", 539 + "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==", 722 540 "license": "MIT", 723 541 "dependencies": { 724 - "@atproto/common-web": "^0.4.10", 725 - "@atproto/lex-cbor": "0.0.6", 726 - "@atproto/lex-data": "0.0.6", 542 + "@atproto/common-web": "^0.4.15", 543 + "@atproto/lex-cbor": "^0.0.10", 544 + "@atproto/lex-data": "^0.0.10", 727 545 "iso-datestring-validator": "^2.2.2", 728 546 "multiformats": "^9.9.0", 729 547 "pino": "^8.21.0" ··· 732 550 "node": ">=18.7.0" 733 551 } 734 552 }, 735 - "node_modules/@atproto/repo/node_modules/@atproto/lexicon": { 736 - "version": "0.6.0", 737 - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.0.tgz", 738 - "integrity": "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==", 553 + "node_modules/@atproto/repo/node_modules/@atproto/lex-cbor": { 554 + "version": "0.0.10", 555 + "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz", 556 + "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==", 557 + "license": "MIT", 558 + "dependencies": { 559 + "@atproto/lex-data": "^0.0.10", 560 + "tslib": "^2.8.1" 561 + } 562 + }, 563 + "node_modules/@atproto/repo/node_modules/@atproto/lex-data": { 564 + "version": "0.0.10", 565 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz", 566 + "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==", 739 567 "license": "MIT", 740 568 "dependencies": { 741 - "@atproto/common-web": "^0.4.7", 742 - "@atproto/syntax": "^0.4.2", 743 - "iso-datestring-validator": "^2.2.2", 744 569 "multiformats": "^9.9.0", 745 - "zod": "^3.23.8" 570 + "tslib": "^2.8.1", 571 + "uint8arrays": "3.0.0", 572 + "unicode-segmenter": "^0.14.0" 746 573 } 747 - }, 748 - "node_modules/@atproto/repo/node_modules/@atproto/syntax": { 749 - "version": "0.4.2", 750 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 751 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 752 - "license": "MIT" 753 574 }, 754 575 "node_modules/@atproto/repo/node_modules/multiformats": { 755 576 "version": "9.9.0", ··· 778 599 } 779 600 }, 780 601 "node_modules/@atproto/sync/node_modules/@atproto/syntax": { 781 - "version": "0.4.1", 782 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", 783 - "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==", 784 - "license": "MIT" 602 + "version": "0.4.3", 603 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 604 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 605 + "license": "MIT", 606 + "dependencies": { 607 + "tslib": "^2.8.1" 608 + } 785 609 }, 786 610 "node_modules/@atproto/sync/node_modules/multiformats": { 787 611 "version": "9.9.0", ··· 794 618 "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.4.tgz", 795 619 "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 796 620 "license": "MIT" 797 - }, 798 - "node_modules/@atproto/tap": { 799 - "version": "0.1.1", 800 - "resolved": "https://registry.npmjs.org/@atproto/tap/-/tap-0.1.1.tgz", 801 - "integrity": "sha512-gW4NzLOxj74TzaDOVzzzt5kl2PdC0r75XkIpYpI5xobwCfsc/DmVtwpuSw1fW9gr4Vzk2Q90S9UE4ifAFl2gyA==", 802 - "license": "MIT", 803 - "dependencies": { 804 - "@atproto/common": "^0.5.6", 805 - "@atproto/lex": "^0.0.9", 806 - "@atproto/syntax": "^0.4.2", 807 - "@atproto/ws-client": "^0.0.4", 808 - "ws": "^8.12.0", 809 - "zod": "^3.23.8" 810 - }, 811 - "engines": { 812 - "node": ">=18.7.0" 813 - } 814 - }, 815 - "node_modules/@atproto/tap/node_modules/@atproto/common": { 816 - "version": "0.5.6", 817 - "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 818 - "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 819 - "license": "MIT", 820 - "dependencies": { 821 - "@atproto/common-web": "^0.4.10", 822 - "@atproto/lex-cbor": "0.0.6", 823 - "@atproto/lex-data": "0.0.6", 824 - "iso-datestring-validator": "^2.2.2", 825 - "multiformats": "^9.9.0", 826 - "pino": "^8.21.0" 827 - }, 828 - "engines": { 829 - "node": ">=18.7.0" 830 - } 831 - }, 832 - "node_modules/@atproto/tap/node_modules/@atproto/syntax": { 833 - "version": "0.4.2", 834 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 835 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 836 - "license": "MIT" 837 - }, 838 - "node_modules/@atproto/tap/node_modules/multiformats": { 839 - "version": "9.9.0", 840 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 841 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 842 - "license": "(Apache-2.0 AND MIT)" 843 - }, 844 - "node_modules/@atproto/ws-client": { 845 - "version": "0.0.4", 846 - "resolved": "https://registry.npmjs.org/@atproto/ws-client/-/ws-client-0.0.4.tgz", 847 - "integrity": "sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==", 848 - "license": "MIT", 849 - "dependencies": { 850 - "@atproto/common": "^0.5.3", 851 - "ws": "^8.12.0" 852 - }, 853 - "engines": { 854 - "node": ">=18.7.0" 855 - } 856 - }, 857 - "node_modules/@atproto/ws-client/node_modules/@atproto/common": { 858 - "version": "0.5.6", 859 - "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 860 - "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 861 - "license": "MIT", 862 - "dependencies": { 863 - "@atproto/common-web": "^0.4.10", 864 - "@atproto/lex-cbor": "0.0.6", 865 - "@atproto/lex-data": "0.0.6", 866 - "iso-datestring-validator": "^2.2.2", 867 - "multiformats": "^9.9.0", 868 - "pino": "^8.21.0" 869 - }, 870 - "engines": { 871 - "node": ">=18.7.0" 872 - } 873 - }, 874 - "node_modules/@atproto/ws-client/node_modules/multiformats": { 875 - "version": "9.9.0", 876 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 877 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 878 - "license": "(Apache-2.0 AND MIT)" 879 621 }, 880 622 "node_modules/@atproto/xrpc": { 881 623 "version": "0.7.5", ··· 1715 1457 } 1716 1458 }, 1717 1459 "node_modules/@esbuild/aix-ppc64": { 1718 - "version": "0.25.4", 1719 - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", 1720 - "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", 1460 + "version": "0.25.12", 1461 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", 1462 + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", 1721 1463 "cpu": [ 1722 1464 "ppc64" 1723 1465 ], 1724 1466 "dev": true, 1467 + "license": "MIT", 1725 1468 "optional": true, 1726 1469 "os": [ 1727 1470 "aix" ··· 1731 1474 } 1732 1475 }, 1733 1476 "node_modules/@esbuild/android-arm": { 1734 - "version": "0.25.4", 1735 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", 1736 - "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", 1477 + "version": "0.25.12", 1478 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", 1479 + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", 1737 1480 "cpu": [ 1738 1481 "arm" 1739 1482 ], 1740 1483 "dev": true, 1484 + "license": "MIT", 1741 1485 "optional": true, 1742 1486 "os": [ 1743 1487 "android" ··· 1747 1491 } 1748 1492 }, 1749 1493 "node_modules/@esbuild/android-arm64": { 1750 - "version": "0.25.4", 1751 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", 1752 - "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", 1494 + "version": "0.25.12", 1495 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", 1496 + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", 1753 1497 "cpu": [ 1754 1498 "arm64" 1755 1499 ], 1756 1500 "dev": true, 1501 + "license": "MIT", 1757 1502 "optional": true, 1758 1503 "os": [ 1759 1504 "android" ··· 1763 1508 } 1764 1509 }, 1765 1510 "node_modules/@esbuild/android-x64": { 1766 - "version": "0.25.4", 1767 - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", 1768 - "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", 1511 + "version": "0.25.12", 1512 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", 1513 + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", 1769 1514 "cpu": [ 1770 1515 "x64" 1771 1516 ], 1772 1517 "dev": true, 1518 + "license": "MIT", 1773 1519 "optional": true, 1774 1520 "os": [ 1775 1521 "android" ··· 1778 1524 "node": ">=18" 1779 1525 } 1780 1526 }, 1527 + "node_modules/@esbuild/darwin-arm64": { 1528 + "version": "0.25.12", 1529 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", 1530 + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", 1531 + "cpu": [ 1532 + "arm64" 1533 + ], 1534 + "dev": true, 1535 + "license": "MIT", 1536 + "optional": true, 1537 + "os": [ 1538 + "darwin" 1539 + ], 1540 + "engines": { 1541 + "node": ">=18" 1542 + } 1543 + }, 1781 1544 "node_modules/@esbuild/darwin-x64": { 1782 - "version": "0.25.4", 1783 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", 1784 - "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", 1545 + "version": "0.25.12", 1546 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", 1547 + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", 1785 1548 "cpu": [ 1786 1549 "x64" 1787 1550 ], 1788 1551 "dev": true, 1552 + "license": "MIT", 1789 1553 "optional": true, 1790 1554 "os": [ 1791 1555 "darwin" ··· 1795 1559 } 1796 1560 }, 1797 1561 "node_modules/@esbuild/freebsd-arm64": { 1798 - "version": "0.25.4", 1799 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", 1800 - "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", 1562 + "version": "0.25.12", 1563 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", 1564 + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", 1801 1565 "cpu": [ 1802 1566 "arm64" 1803 1567 ], 1804 1568 "dev": true, 1569 + "license": "MIT", 1805 1570 "optional": true, 1806 1571 "os": [ 1807 1572 "freebsd" ··· 1811 1576 } 1812 1577 }, 1813 1578 "node_modules/@esbuild/freebsd-x64": { 1814 - "version": "0.25.4", 1815 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", 1816 - "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", 1579 + "version": "0.25.12", 1580 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", 1581 + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", 1817 1582 "cpu": [ 1818 1583 "x64" 1819 1584 ], 1820 1585 "dev": true, 1586 + "license": "MIT", 1821 1587 "optional": true, 1822 1588 "os": [ 1823 1589 "freebsd" ··· 1827 1593 } 1828 1594 }, 1829 1595 "node_modules/@esbuild/linux-arm": { 1830 - "version": "0.25.4", 1831 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", 1832 - "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", 1596 + "version": "0.25.12", 1597 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", 1598 + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", 1833 1599 "cpu": [ 1834 1600 "arm" 1835 1601 ], 1836 1602 "dev": true, 1603 + "license": "MIT", 1837 1604 "optional": true, 1838 1605 "os": [ 1839 1606 "linux" ··· 1843 1610 } 1844 1611 }, 1845 1612 "node_modules/@esbuild/linux-arm64": { 1846 - "version": "0.25.4", 1847 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", 1848 - "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", 1613 + "version": "0.25.12", 1614 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", 1615 + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", 1849 1616 "cpu": [ 1850 1617 "arm64" 1851 1618 ], 1852 1619 "dev": true, 1620 + "license": "MIT", 1853 1621 "optional": true, 1854 1622 "os": [ 1855 1623 "linux" ··· 1859 1627 } 1860 1628 }, 1861 1629 "node_modules/@esbuild/linux-ia32": { 1862 - "version": "0.25.4", 1863 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", 1864 - "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", 1630 + "version": "0.25.12", 1631 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", 1632 + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", 1865 1633 "cpu": [ 1866 1634 "ia32" 1867 1635 ], 1868 1636 "dev": true, 1637 + "license": "MIT", 1869 1638 "optional": true, 1870 1639 "os": [ 1871 1640 "linux" ··· 1875 1644 } 1876 1645 }, 1877 1646 "node_modules/@esbuild/linux-loong64": { 1878 - "version": "0.25.4", 1879 - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", 1880 - "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", 1647 + "version": "0.25.12", 1648 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", 1649 + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", 1881 1650 "cpu": [ 1882 1651 "loong64" 1883 1652 ], 1884 1653 "dev": true, 1654 + "license": "MIT", 1885 1655 "optional": true, 1886 1656 "os": [ 1887 1657 "linux" ··· 1891 1661 } 1892 1662 }, 1893 1663 "node_modules/@esbuild/linux-mips64el": { 1894 - "version": "0.25.4", 1895 - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", 1896 - "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", 1664 + "version": "0.25.12", 1665 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", 1666 + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", 1897 1667 "cpu": [ 1898 1668 "mips64el" 1899 1669 ], 1900 1670 "dev": true, 1671 + "license": "MIT", 1901 1672 "optional": true, 1902 1673 "os": [ 1903 1674 "linux" ··· 1907 1678 } 1908 1679 }, 1909 1680 "node_modules/@esbuild/linux-ppc64": { 1910 - "version": "0.25.4", 1911 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", 1912 - "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", 1681 + "version": "0.25.12", 1682 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", 1683 + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", 1913 1684 "cpu": [ 1914 1685 "ppc64" 1915 1686 ], 1916 1687 "dev": true, 1688 + "license": "MIT", 1917 1689 "optional": true, 1918 1690 "os": [ 1919 1691 "linux" ··· 1923 1695 } 1924 1696 }, 1925 1697 "node_modules/@esbuild/linux-riscv64": { 1926 - "version": "0.25.4", 1927 - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", 1928 - "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", 1698 + "version": "0.25.12", 1699 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", 1700 + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", 1929 1701 "cpu": [ 1930 1702 "riscv64" 1931 1703 ], 1932 1704 "dev": true, 1705 + "license": "MIT", 1933 1706 "optional": true, 1934 1707 "os": [ 1935 1708 "linux" ··· 1939 1712 } 1940 1713 }, 1941 1714 "node_modules/@esbuild/linux-s390x": { 1942 - "version": "0.25.4", 1943 - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", 1944 - "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", 1715 + "version": "0.25.12", 1716 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", 1717 + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", 1945 1718 "cpu": [ 1946 1719 "s390x" 1947 1720 ], 1948 1721 "dev": true, 1722 + "license": "MIT", 1949 1723 "optional": true, 1950 1724 "os": [ 1951 1725 "linux" ··· 1955 1729 } 1956 1730 }, 1957 1731 "node_modules/@esbuild/linux-x64": { 1958 - "version": "0.25.4", 1959 - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", 1960 - "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", 1732 + "version": "0.25.12", 1733 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", 1734 + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", 1961 1735 "cpu": [ 1962 1736 "x64" 1963 1737 ], ··· 1972 1746 } 1973 1747 }, 1974 1748 "node_modules/@esbuild/netbsd-arm64": { 1975 - "version": "0.25.4", 1976 - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", 1977 - "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", 1749 + "version": "0.25.12", 1750 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", 1751 + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", 1978 1752 "cpu": [ 1979 1753 "arm64" 1980 1754 ], 1981 1755 "dev": true, 1756 + "license": "MIT", 1982 1757 "optional": true, 1983 1758 "os": [ 1984 1759 "netbsd" ··· 1988 1763 } 1989 1764 }, 1990 1765 "node_modules/@esbuild/netbsd-x64": { 1991 - "version": "0.25.4", 1992 - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", 1993 - "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", 1766 + "version": "0.25.12", 1767 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", 1768 + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", 1994 1769 "cpu": [ 1995 1770 "x64" 1996 1771 ], 1997 1772 "dev": true, 1773 + "license": "MIT", 1998 1774 "optional": true, 1999 1775 "os": [ 2000 1776 "netbsd" ··· 2004 1780 } 2005 1781 }, 2006 1782 "node_modules/@esbuild/openbsd-arm64": { 2007 - "version": "0.25.4", 2008 - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", 2009 - "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", 1783 + "version": "0.25.12", 1784 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", 1785 + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", 2010 1786 "cpu": [ 2011 1787 "arm64" 2012 1788 ], 2013 1789 "dev": true, 1790 + "license": "MIT", 2014 1791 "optional": true, 2015 1792 "os": [ 2016 1793 "openbsd" ··· 2020 1797 } 2021 1798 }, 2022 1799 "node_modules/@esbuild/openbsd-x64": { 2023 - "version": "0.25.4", 2024 - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", 2025 - "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", 1800 + "version": "0.25.12", 1801 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", 1802 + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", 2026 1803 "cpu": [ 2027 1804 "x64" 2028 1805 ], 2029 1806 "dev": true, 1807 + "license": "MIT", 2030 1808 "optional": true, 2031 1809 "os": [ 2032 1810 "openbsd" ··· 2035 1813 "node": ">=18" 2036 1814 } 2037 1815 }, 1816 + "node_modules/@esbuild/openharmony-arm64": { 1817 + "version": "0.25.12", 1818 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", 1819 + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", 1820 + "cpu": [ 1821 + "arm64" 1822 + ], 1823 + "dev": true, 1824 + "license": "MIT", 1825 + "optional": true, 1826 + "os": [ 1827 + "openharmony" 1828 + ], 1829 + "engines": { 1830 + "node": ">=18" 1831 + } 1832 + }, 2038 1833 "node_modules/@esbuild/sunos-x64": { 2039 - "version": "0.25.4", 2040 - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", 2041 - "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", 1834 + "version": "0.25.12", 1835 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", 1836 + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", 2042 1837 "cpu": [ 2043 1838 "x64" 2044 1839 ], 2045 1840 "dev": true, 1841 + "license": "MIT", 2046 1842 "optional": true, 2047 1843 "os": [ 2048 1844 "sunos" ··· 2052 1848 } 2053 1849 }, 2054 1850 "node_modules/@esbuild/win32-arm64": { 2055 - "version": "0.25.4", 2056 - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", 2057 - "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", 1851 + "version": "0.25.12", 1852 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", 1853 + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", 2058 1854 "cpu": [ 2059 1855 "arm64" 2060 1856 ], 2061 1857 "dev": true, 1858 + "license": "MIT", 2062 1859 "optional": true, 2063 1860 "os": [ 2064 1861 "win32" ··· 2068 1865 } 2069 1866 }, 2070 1867 "node_modules/@esbuild/win32-ia32": { 2071 - "version": "0.25.4", 2072 - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", 2073 - "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", 1868 + "version": "0.25.12", 1869 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", 1870 + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", 2074 1871 "cpu": [ 2075 1872 "ia32" 2076 1873 ], 2077 1874 "dev": true, 1875 + "license": "MIT", 2078 1876 "optional": true, 2079 1877 "os": [ 2080 1878 "win32" ··· 2084 1882 } 2085 1883 }, 2086 1884 "node_modules/@esbuild/win32-x64": { 2087 - "version": "0.25.4", 2088 - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", 2089 - "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", 1885 + "version": "0.25.12", 1886 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", 1887 + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", 2090 1888 "cpu": [ 2091 1889 "x64" 2092 1890 ], 2093 1891 "dev": true, 1892 + "license": "MIT", 2094 1893 "optional": true, 2095 1894 "os": [ 2096 1895 "win32" ··· 2895 2694 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 2896 2695 "license": "(Apache-2.0 AND MIT)" 2897 2696 }, 2898 - "node_modules/@isaacs/balanced-match": { 2899 - "version": "4.0.1", 2900 - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", 2901 - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", 2902 - "license": "MIT", 2903 - "engines": { 2904 - "node": "20 || >=22" 2905 - } 2906 - }, 2907 - "node_modules/@isaacs/brace-expansion": { 2908 - "version": "5.0.0", 2909 - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", 2910 - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", 2911 - "license": "MIT", 2912 - "dependencies": { 2913 - "@isaacs/balanced-match": "^4.0.1" 2914 - }, 2915 - "engines": { 2916 - "node": "20 || >=22" 2917 - } 2918 - }, 2919 2697 "node_modules/@isaacs/fs-minipass": { 2920 2698 "version": "4.0.1", 2921 2699 "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", ··· 3015 2793 } 3016 2794 }, 3017 2795 "node_modules/@mdx-js/loader/node_modules/source-map": { 3018 - "version": "0.7.4", 3019 - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", 3020 - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", 2796 + "version": "0.7.6", 2797 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", 2798 + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", 2799 + "license": "BSD-3-Clause", 3021 2800 "engines": { 3022 - "node": ">= 8" 2801 + "node": ">= 12" 3023 2802 } 3024 2803 }, 3025 2804 "node_modules/@mdx-js/mdx": { ··· 3066 2845 } 3067 2846 }, 3068 2847 "node_modules/@mdx-js/mdx/node_modules/source-map": { 3069 - "version": "0.7.4", 3070 - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", 3071 - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", 2848 + "version": "0.7.6", 2849 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", 2850 + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", 2851 + "license": "BSD-3-Clause", 3072 2852 "engines": { 3073 - "node": ">= 8" 2853 + "node": ">= 12" 3074 2854 } 3075 2855 }, 3076 2856 "node_modules/@mdx-js/react": { ··· 3161 2941 } 3162 2942 }, 3163 2943 "node_modules/@next/mdx/node_modules/source-map": { 3164 - "version": "0.7.4", 3165 - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", 3166 - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", 2944 + "version": "0.7.6", 2945 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", 2946 + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", 2947 + "license": "BSD-3-Clause", 3167 2948 "engines": { 3168 - "node": ">= 8" 2949 + "node": ">= 12" 3169 2950 } 3170 2951 }, 3171 2952 "node_modules/@next/swc-darwin-arm64": { ··· 7550 7331 } 7551 7332 }, 7552 7333 "node_modules/@tailwindcss/node/node_modules/magic-string": { 7553 - "version": "0.30.19", 7554 - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", 7555 - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", 7334 + "version": "0.30.21", 7335 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 7336 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 7556 7337 "dev": true, 7557 7338 "license": "MIT", 7558 7339 "dependencies": { ··· 7806 7587 } 7807 7588 }, 7808 7589 "node_modules/@tailwindcss/oxide/node_modules/tar": { 7809 - "version": "7.5.1", 7810 - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", 7811 - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", 7590 + "version": "7.5.7", 7591 + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", 7592 + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", 7812 7593 "dev": true, 7813 - "license": "ISC", 7594 + "license": "BlueOak-1.0.0", 7814 7595 "dependencies": { 7815 7596 "@isaacs/fs-minipass": "^4.0.0", 7816 7597 "chownr": "^3.0.0", ··· 8143 7924 } 8144 7925 }, 8145 7926 "node_modules/@types/unist": { 8146 - "version": "3.0.2", 8147 - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", 8148 - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" 7927 + "version": "3.0.3", 7928 + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", 7929 + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", 7930 + "license": "MIT" 8149 7931 }, 8150 7932 "node_modules/@types/uuid": { 8151 7933 "version": "10.0.0", ··· 8501 8283 } 8502 8284 } 8503 8285 }, 8286 + "node_modules/@yornaath/batshit": { 8287 + "version": "0.14.0", 8288 + "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz", 8289 + "integrity": "sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==", 8290 + "license": "MIT", 8291 + "dependencies": { 8292 + "@yornaath/batshit-devtools": "^1.7.1" 8293 + } 8294 + }, 8295 + "node_modules/@yornaath/batshit-devtools": { 8296 + "version": "1.7.1", 8297 + "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz", 8298 + "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==", 8299 + "license": "MIT" 8300 + }, 8504 8301 "node_modules/abort-controller": { 8505 8302 "version": "3.0.0", 8506 8303 "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", ··· 8564 8361 } 8565 8362 }, 8566 8363 "node_modules/agent-base": { 8567 - "version": "7.1.1", 8568 - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", 8569 - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", 8570 - "dependencies": { 8571 - "debug": "^4.3.4" 8572 - }, 8364 + "version": "7.1.4", 8365 + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", 8366 + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", 8367 + "license": "MIT", 8573 8368 "engines": { 8574 8369 "node": ">= 14" 8575 8370 } ··· 9048 8843 "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", 9049 8844 "license": "MIT" 9050 8845 }, 9051 - "node_modules/body-parser/node_modules/qs": { 9052 - "version": "6.13.0", 9053 - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 9054 - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 9055 - "license": "BSD-3-Clause", 9056 - "dependencies": { 9057 - "side-channel": "^1.0.6" 9058 - }, 9059 - "engines": { 9060 - "node": ">=0.6" 9061 - }, 9062 - "funding": { 9063 - "url": "https://github.com/sponsors/ljharb" 9064 - } 9065 - }, 9066 8846 "node_modules/brace-expansion": { 9067 - "version": "1.1.11", 9068 - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 9069 - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 8847 + "version": "1.1.12", 8848 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", 8849 + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", 9070 8850 "dev": true, 8851 + "license": "MIT", 9071 8852 "dependencies": { 9072 8853 "balanced-match": "^1.0.0", 9073 8854 "concat-map": "0.0.1" ··· 9471 9252 "version": "13.0.3", 9472 9253 "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 9473 9254 "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 9255 + "dev": true, 9474 9256 "license": "MIT" 9475 9257 }, 9476 9258 "node_modules/collapse-white-space": { ··· 9578 9360 "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 9579 9361 "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", 9580 9362 "license": "MIT" 9581 - }, 9582 - "node_modules/core-js": { 9583 - "version": "3.47.0", 9584 - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", 9585 - "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", 9586 - "hasInstallScript": true, 9587 - "license": "MIT", 9588 - "funding": { 9589 - "type": "opencollective", 9590 - "url": "https://opencollective.com/core-js" 9591 - } 9592 9363 }, 9593 9364 "node_modules/crelt": { 9594 9365 "version": "1.0.6", ··· 9749 9520 "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" 9750 9521 }, 9751 9522 "node_modules/debug": { 9752 - "version": "4.4.1", 9753 - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", 9754 - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", 9523 + "version": "4.4.3", 9524 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 9525 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 9755 9526 "license": "MIT", 9756 9527 "dependencies": { 9757 9528 "ms": "^2.1.3" ··· 9903 9674 }, 9904 9675 "engines": { 9905 9676 "node": "*" 9677 + } 9678 + }, 9679 + "node_modules/doctrine": { 9680 + "version": "2.1.0", 9681 + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", 9682 + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", 9683 + "dev": true, 9684 + "license": "Apache-2.0", 9685 + "dependencies": { 9686 + "esutils": "^2.0.2" 9687 + }, 9688 + "engines": { 9689 + "node": ">=0.10.0" 9906 9690 } 9907 9691 }, 9908 9692 "node_modules/dreamopt": { ··· 10801 10585 } 10802 10586 }, 10803 10587 "node_modules/esbuild": { 10804 - "version": "0.25.4", 10805 - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", 10806 - "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", 10588 + "version": "0.25.12", 10589 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", 10590 + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", 10807 10591 "dev": true, 10808 10592 "hasInstallScript": true, 10809 10593 "license": "MIT", ··· 10814 10598 "node": ">=18" 10815 10599 }, 10816 10600 "optionalDependencies": { 10817 - "@esbuild/aix-ppc64": "0.25.4", 10818 - "@esbuild/android-arm": "0.25.4", 10819 - "@esbuild/android-arm64": "0.25.4", 10820 - "@esbuild/android-x64": "0.25.4", 10821 - "@esbuild/darwin-arm64": "0.25.4", 10822 - "@esbuild/darwin-x64": "0.25.4", 10823 - "@esbuild/freebsd-arm64": "0.25.4", 10824 - "@esbuild/freebsd-x64": "0.25.4", 10825 - "@esbuild/linux-arm": "0.25.4", 10826 - "@esbuild/linux-arm64": "0.25.4", 10827 - "@esbuild/linux-ia32": "0.25.4", 10828 - "@esbuild/linux-loong64": "0.25.4", 10829 - "@esbuild/linux-mips64el": "0.25.4", 10830 - "@esbuild/linux-ppc64": "0.25.4", 10831 - "@esbuild/linux-riscv64": "0.25.4", 10832 - "@esbuild/linux-s390x": "0.25.4", 10833 - "@esbuild/linux-x64": "0.25.4", 10834 - "@esbuild/netbsd-arm64": "0.25.4", 10835 - "@esbuild/netbsd-x64": "0.25.4", 10836 - "@esbuild/openbsd-arm64": "0.25.4", 10837 - "@esbuild/openbsd-x64": "0.25.4", 10838 - "@esbuild/sunos-x64": "0.25.4", 10839 - "@esbuild/win32-arm64": "0.25.4", 10840 - "@esbuild/win32-ia32": "0.25.4", 10841 - "@esbuild/win32-x64": "0.25.4" 10601 + "@esbuild/aix-ppc64": "0.25.12", 10602 + "@esbuild/android-arm": "0.25.12", 10603 + "@esbuild/android-arm64": "0.25.12", 10604 + "@esbuild/android-x64": "0.25.12", 10605 + "@esbuild/darwin-arm64": "0.25.12", 10606 + "@esbuild/darwin-x64": "0.25.12", 10607 + "@esbuild/freebsd-arm64": "0.25.12", 10608 + "@esbuild/freebsd-x64": "0.25.12", 10609 + "@esbuild/linux-arm": "0.25.12", 10610 + "@esbuild/linux-arm64": "0.25.12", 10611 + "@esbuild/linux-ia32": "0.25.12", 10612 + "@esbuild/linux-loong64": "0.25.12", 10613 + "@esbuild/linux-mips64el": "0.25.12", 10614 + "@esbuild/linux-ppc64": "0.25.12", 10615 + "@esbuild/linux-riscv64": "0.25.12", 10616 + "@esbuild/linux-s390x": "0.25.12", 10617 + "@esbuild/linux-x64": "0.25.12", 10618 + "@esbuild/netbsd-arm64": "0.25.12", 10619 + "@esbuild/netbsd-x64": "0.25.12", 10620 + "@esbuild/openbsd-arm64": "0.25.12", 10621 + "@esbuild/openbsd-x64": "0.25.12", 10622 + "@esbuild/openharmony-arm64": "0.25.12", 10623 + "@esbuild/sunos-x64": "0.25.12", 10624 + "@esbuild/win32-arm64": "0.25.12", 10625 + "@esbuild/win32-ia32": "0.25.12", 10626 + "@esbuild/win32-x64": "0.25.12" 10842 10627 } 10843 10628 }, 10844 10629 "node_modules/esbuild-register": { ··· 10851 10636 }, 10852 10637 "peerDependencies": { 10853 10638 "esbuild": ">=0.12 <1" 10854 - } 10855 - }, 10856 - "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { 10857 - "version": "0.25.4", 10858 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", 10859 - "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", 10860 - "cpu": [ 10861 - "arm64" 10862 - ], 10863 - "dev": true, 10864 - "optional": true, 10865 - "os": [ 10866 - "darwin" 10867 - ], 10868 - "engines": { 10869 - "node": ">=18" 10870 10639 } 10871 10640 }, 10872 10641 "node_modules/escalade": { ··· 11104 10873 "ms": "^2.1.1" 11105 10874 } 11106 10875 }, 11107 - "node_modules/eslint-plugin-import/node_modules/doctrine": { 11108 - "version": "2.1.0", 11109 - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", 11110 - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", 11111 - "dev": true, 11112 - "dependencies": { 11113 - "esutils": "^2.0.2" 11114 - }, 11115 - "engines": { 11116 - "node": ">=0.10.0" 11117 - } 11118 - }, 11119 10876 "node_modules/eslint-plugin-import/node_modules/semver": { 11120 10877 "version": "6.3.1", 11121 10878 "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", ··· 11207 10964 "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" 11208 10965 } 11209 10966 }, 11210 - "node_modules/eslint-plugin-react-hooks/node_modules/zod": { 11211 - "version": "4.1.12", 11212 - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", 11213 - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", 11214 - "dev": true, 11215 - "funding": { 11216 - "url": "https://github.com/sponsors/colinhacks" 11217 - } 11218 - }, 11219 10967 "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { 11220 10968 "version": "4.0.2", 11221 10969 "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", ··· 11228 10976 "zod": "^3.25.0 || ^4.0.0" 11229 10977 } 11230 10978 }, 11231 - "node_modules/eslint-plugin-react/node_modules/doctrine": { 11232 - "version": "2.1.0", 11233 - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", 11234 - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", 11235 - "dev": true, 11236 - "license": "Apache-2.0", 11237 - "dependencies": { 11238 - "esutils": "^2.0.2" 11239 - }, 11240 - "engines": { 11241 - "node": ">=0.10.0" 11242 - } 11243 - }, 11244 10979 "node_modules/eslint-plugin-react/node_modules/resolve": { 11245 10980 "version": "2.0.0-next.5", 11246 10981 "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", ··· 11434 11169 } 11435 11170 }, 11436 11171 "node_modules/estree-util-to-js/node_modules/source-map": { 11437 - "version": "0.7.4", 11438 - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", 11439 - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", 11172 + "version": "0.7.6", 11173 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", 11174 + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", 11175 + "license": "BSD-3-Clause", 11440 11176 "engines": { 11441 - "node": ">= 8" 11177 + "node": ">= 12" 11442 11178 } 11443 11179 }, 11444 11180 "node_modules/estree-util-visit": { ··· 11600 11336 "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", 11601 11337 "license": "MIT" 11602 11338 }, 11603 - "node_modules/express/node_modules/qs": { 11604 - "version": "6.13.0", 11605 - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 11606 - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 11607 - "license": "BSD-3-Clause", 11608 - "dependencies": { 11609 - "side-channel": "^1.0.6" 11610 - }, 11611 - "engines": { 11612 - "node": ">=0.6" 11613 - }, 11614 - "funding": { 11615 - "url": "https://github.com/sponsors/ljharb" 11616 - } 11617 - }, 11618 11339 "node_modules/ext": { 11619 11340 "version": "1.7.0", 11620 11341 "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", ··· 11636 11357 "dev": true 11637 11358 }, 11638 11359 "node_modules/fast-glob": { 11639 - "version": "3.3.2", 11640 - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", 11641 - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", 11360 + "version": "3.3.3", 11361 + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", 11362 + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", 11642 11363 "dev": true, 11364 + "license": "MIT", 11643 11365 "dependencies": { 11644 11366 "@nodelib/fs.stat": "^2.0.2", 11645 11367 "@nodelib/fs.walk": "^1.2.3", 11646 11368 "glob-parent": "^5.1.2", 11647 11369 "merge2": "^1.3.0", 11648 - "micromatch": "^4.0.4" 11370 + "micromatch": "^4.0.8" 11649 11371 }, 11650 11372 "engines": { 11651 11373 "node": ">=8.6.0" ··· 11929 11651 "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", 11930 11652 "dev": true 11931 11653 }, 11654 + "node_modules/fsevents": { 11655 + "version": "2.3.3", 11656 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 11657 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 11658 + "dev": true, 11659 + "hasInstallScript": true, 11660 + "license": "MIT", 11661 + "optional": true, 11662 + "os": [ 11663 + "darwin" 11664 + ], 11665 + "engines": { 11666 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 11667 + } 11668 + }, 11932 11669 "node_modules/function-bind": { 11933 11670 "version": "1.1.2", 11934 11671 "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", ··· 12179 11916 "dev": true 12180 11917 }, 12181 11918 "node_modules/glob/node_modules/brace-expansion": { 12182 - "version": "2.0.1", 12183 - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", 12184 - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 11919 + "version": "2.0.2", 11920 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", 11921 + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", 12185 11922 "dev": true, 11923 + "license": "MIT", 12186 11924 "dependencies": { 12187 11925 "balanced-match": "^1.0.0" 12188 11926 } ··· 12722 12460 } 12723 12461 }, 12724 12462 "node_modules/https-proxy-agent": { 12725 - "version": "7.0.4", 12726 - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", 12727 - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", 12463 + "version": "7.0.6", 12464 + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", 12465 + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", 12466 + "license": "MIT", 12728 12467 "dependencies": { 12729 - "agent-base": "^7.0.2", 12468 + "agent-base": "^7.1.2", 12730 12469 "debug": "4" 12731 12470 }, 12732 12471 "engines": { ··· 12766 12505 "license": "BSD-3-Clause" 12767 12506 }, 12768 12507 "node_modules/ignore": { 12769 - "version": "5.3.1", 12770 - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", 12771 - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", 12508 + "version": "5.3.2", 12509 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 12510 + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 12772 12511 "dev": true, 12512 + "license": "MIT", 12773 12513 "engines": { 12774 12514 "node": ">= 4" 12775 12515 } ··· 12838 12578 "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 12839 12579 }, 12840 12580 "node_modules/inline-style-parser": { 12841 - "version": "0.2.4", 12842 - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", 12843 - "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==" 12581 + "version": "0.2.7", 12582 + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", 12583 + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", 12584 + "license": "MIT" 12844 12585 }, 12845 12586 "node_modules/inngest": { 12846 12587 "version": "3.40.1", ··· 13001 12742 } 13002 12743 }, 13003 12744 "node_modules/ipaddr.js": { 13004 - "version": "2.2.0", 13005 - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", 13006 - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", 12745 + "version": "2.3.0", 12746 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", 12747 + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", 13007 12748 "license": "MIT", 13008 12749 "engines": { 13009 12750 "node": ">= 10" ··· 14174 13915 } 14175 13916 }, 14176 13917 "node_modules/lru-cache": { 14177 - "version": "10.2.2", 14178 - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", 14179 - "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", 14180 - "engines": { 14181 - "node": "14 || >=16.14" 14182 - } 13918 + "version": "10.4.3", 13919 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 13920 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 13921 + "license": "ISC" 14183 13922 }, 14184 13923 "node_modules/lru-queue": { 14185 13924 "version": "0.1.0", ··· 15283 15022 ] 15284 15023 }, 15285 15024 "node_modules/micromatch": { 15286 - "version": "4.0.7", 15287 - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", 15288 - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", 15025 + "version": "4.0.8", 15026 + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", 15027 + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 15289 15028 "dev": true, 15029 + "license": "MIT", 15290 15030 "dependencies": { 15291 15031 "braces": "^3.0.3", 15292 15032 "picomatch": "^2.3.1" ··· 15353 15093 } 15354 15094 }, 15355 15095 "node_modules/miniflare/node_modules/undici": { 15356 - "version": "5.28.4", 15357 - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", 15358 - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", 15096 + "version": "5.29.0", 15097 + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 15098 + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 15359 15099 "dev": true, 15100 + "license": "MIT", 15360 15101 "dependencies": { 15361 15102 "@fastify/busboy": "^2.0.0" 15362 15103 }, ··· 15450 15191 "license": "MIT" 15451 15192 }, 15452 15193 "node_modules/multiformats": { 15453 - "version": "13.3.2", 15454 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.2.tgz", 15455 - "integrity": "sha512-qbB0CQDt3QKfiAzZ5ZYjLFOs+zW43vA4uyM8g27PeEuXZybUOFyjrVdP93HPBHMoglibwfkdVwbzfUq8qGcH6g==", 15194 + "version": "13.4.2", 15195 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", 15196 + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 15456 15197 "license": "Apache-2.0 OR MIT" 15457 15198 }, 15458 15199 "node_modules/mustache": { ··· 16012 15753 "version": "1.0.1", 16013 15754 "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", 16014 15755 "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", 15756 + "dev": true, 16015 15757 "license": "MIT" 16016 15758 }, 16017 15759 "node_modules/path-exists": { ··· 16038 15780 "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" 16039 15781 }, 16040 15782 "node_modules/path-to-regexp": { 16041 - "version": "6.2.2", 16042 - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", 16043 - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", 16044 - "dev": true 15783 + "version": "6.3.0", 15784 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", 15785 + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 15786 + "dev": true, 15787 + "license": "MIT" 16045 15788 }, 16046 15789 "node_modules/pg": { 16047 15790 "version": "8.16.3", ··· 16293 16036 "version": "3.2.5", 16294 16037 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", 16295 16038 "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", 16039 + "dev": true, 16296 16040 "bin": { 16297 16041 "prettier": "bin/prettier.cjs" 16298 16042 }, ··· 16511 16255 "prosemirror-view": "^1.37.2" 16512 16256 } 16513 16257 }, 16514 - "node_modules/prosemirror-tables/node_modules/prosemirror-view": { 16515 - "version": "1.39.2", 16516 - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.2.tgz", 16517 - "integrity": "sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg==", 16518 - "license": "MIT", 16519 - "peer": true, 16520 - "dependencies": { 16521 - "prosemirror-model": "^1.20.0", 16522 - "prosemirror-state": "^1.0.0", 16523 - "prosemirror-transform": "^1.1.0" 16524 - } 16525 - }, 16526 16258 "node_modules/prosemirror-trailing-node": { 16527 16259 "version": "3.0.0", 16528 16260 "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", ··· 16549 16281 } 16550 16282 }, 16551 16283 "node_modules/prosemirror-view": { 16552 - "version": "1.37.1", 16553 - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.37.1.tgz", 16554 - "integrity": "sha512-MEAnjOdXU1InxEmhjgmEzQAikaS6lF3hD64MveTPpjOGNTl87iRLA1HupC/DEV6YuK7m4Q9DHFNTjwIVtqz5NA==", 16284 + "version": "1.41.5", 16285 + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz", 16286 + "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==", 16555 16287 "license": "MIT", 16556 16288 "dependencies": { 16557 16289 "prosemirror-model": "^1.20.0", ··· 16630 16362 } 16631 16363 }, 16632 16364 "node_modules/qs": { 16633 - "version": "6.13.1", 16634 - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", 16635 - "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", 16365 + "version": "6.13.0", 16366 + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 16367 + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 16636 16368 "license": "BSD-3-Clause", 16637 16369 "dependencies": { 16638 16370 "side-channel": "^1.0.6" ··· 17372 17104 } 17373 17105 }, 17374 17106 "node_modules/resolve": { 17375 - "version": "1.22.8", 17376 - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", 17377 - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", 17107 + "version": "1.22.11", 17108 + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", 17109 + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", 17110 + "license": "MIT", 17378 17111 "dependencies": { 17379 - "is-core-module": "^2.13.0", 17112 + "is-core-module": "^2.16.1", 17380 17113 "path-parse": "^1.0.7", 17381 17114 "supports-preserve-symlinks-flag": "^1.0.0" 17382 17115 }, 17383 17116 "bin": { 17384 17117 "resolve": "bin/resolve" 17118 + }, 17119 + "engines": { 17120 + "node": ">= 0.4" 17385 17121 }, 17386 17122 "funding": { 17387 17123 "url": "https://github.com/sponsors/ljharb" ··· 17605 17341 } 17606 17342 }, 17607 17343 "node_modules/semver": { 17608 - "version": "7.7.2", 17609 - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", 17610 - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", 17344 + "version": "7.7.3", 17345 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", 17346 + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", 17611 17347 "license": "ISC", 17612 17348 "bin": { 17613 17349 "semver": "bin/semver.js" ··· 18253 17989 } 18254 17990 }, 18255 17991 "node_modules/style-to-object": { 18256 - "version": "1.0.8", 18257 - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", 18258 - "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", 17992 + "version": "1.0.14", 17993 + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", 17994 + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", 17995 + "license": "MIT", 18259 17996 "dependencies": { 18260 - "inline-style-parser": "0.2.4" 17997 + "inline-style-parser": "0.2.7" 18261 17998 } 18262 17999 }, 18263 18000 "node_modules/styled-jsx": { ··· 18413 18150 "version": "0.2.15", 18414 18151 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 18415 18152 "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 18153 + "dev": true, 18416 18154 "license": "MIT", 18417 18155 "dependencies": { 18418 18156 "fdir": "^6.5.0", ··· 18429 18167 "version": "6.5.0", 18430 18168 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 18431 18169 "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 18170 + "dev": true, 18432 18171 "license": "MIT", 18433 18172 "engines": { 18434 18173 "node": ">=12.0.0" ··· 18446 18185 "version": "4.0.3", 18447 18186 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 18448 18187 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 18188 + "dev": true, 18449 18189 "license": "MIT", 18450 18190 "engines": { 18451 18191 "node": ">=12" ··· 18823 18563 } 18824 18564 }, 18825 18565 "node_modules/undici": { 18826 - "version": "6.21.3", 18827 - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", 18828 - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", 18566 + "version": "6.23.0", 18567 + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", 18568 + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", 18829 18569 "license": "MIT", 18830 18570 "engines": { 18831 18571 "node": ">=18.17" ··· 19823 19563 } 19824 19564 }, 19825 19565 "node_modules/ws": { 19826 - "version": "8.17.0", 19827 - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", 19828 - "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", 19566 + "version": "8.19.0", 19567 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", 19568 + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", 19569 + "license": "MIT", 19829 19570 "engines": { 19830 19571 "node": ">=10.0.0" 19831 19572 }, ··· 20013 19754 } 20014 19755 }, 20015 19756 "node_modules/zod": { 20016 - "version": "3.23.8", 20017 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", 20018 - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", 19757 + "version": "3.25.76", 19758 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 19759 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 19760 + "license": "MIT", 20019 19761 "funding": { 20020 19762 "url": "https://github.com/sponsors/colinhacks" 20021 19763 }
+4 -3
package.json
··· 23 23 "@atproto/api": "^0.16.9", 24 24 "@atproto/common": "^0.4.8", 25 25 "@atproto/identity": "^0.4.6", 26 - "@atproto/lexicon": "^0.5.1", 26 + "@atproto/lexicon": "^0.6.1", 27 27 "@atproto/oauth-client-node": "^0.3.8", 28 28 "@atproto/sync": "^0.1.34", 29 29 "@atproto/syntax": "^0.3.3", 30 - "@atproto/tap": "^0.1.1", 31 30 "@atproto/xrpc": "^0.7.5", 32 31 "@atproto/xrpc-server": "^0.9.5", 33 32 "@hono/node-server": "^1.14.3", ··· 49 48 "@vercel/analytics": "^1.5.0", 50 49 "@vercel/functions": "^2.2.12", 51 50 "@vercel/sdk": "^1.11.4", 51 + "@yornaath/batshit": "^0.14.0", 52 52 "babel-plugin-react-compiler": "^19.1.0-rc.1", 53 53 "base64-js": "^1.5.1", 54 54 "colorjs.io": "^0.5.2", ··· 125 125 "ajv": "^8.17.1", 126 126 "whatwg-url": "^14.0.0", 127 127 "@types/react": "19.2.6", 128 - "@types/react-dom": "19.2.3" 128 + "@types/react-dom": "19.2.3", 129 + "@atproto/lexicon": "^0.6.1" 129 130 } 130 131 }
+59 -4
src/notifications.ts
··· 27 27 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } 28 28 | { type: "comment_mention"; comment_uri: string; mention_type: "did" } 29 29 | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } 30 - | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; 30 + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string } 31 + | { type: "recommend"; document_uri: string; recommend_uri: string }; 31 32 32 33 export type HydratedNotification = 33 34 | HydratedCommentNotification ··· 35 36 | HydratedQuoteNotification 36 37 | HydratedBskyPostEmbedNotification 37 38 | HydratedMentionNotification 38 - | HydratedCommentMentionNotification; 39 + | HydratedCommentMentionNotification 40 + | HydratedRecommendNotification; 39 41 export async function hydrateNotifications( 40 42 notifications: NotificationRow[], 41 43 ): Promise<Array<HydratedNotification>> { 42 44 // Call all hydrators in parallel 43 - const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 45 + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications, recommendNotifications] = await Promise.all([ 44 46 hydrateCommentNotifications(notifications), 45 47 hydrateSubscribeNotifications(notifications), 46 48 hydrateQuoteNotifications(notifications), 47 49 hydrateBskyPostEmbedNotifications(notifications), 48 50 hydrateMentionNotifications(notifications), 49 51 hydrateCommentMentionNotifications(notifications), 52 + hydrateRecommendNotifications(notifications), 50 53 ]); 51 54 52 55 // Combine all hydrated notifications 53 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; 56 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications, ...recommendNotifications]; 54 57 55 58 // Sort by created_at to maintain order 56 59 allHydrated.sort( ··· 514 517 ), 515 518 normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), 516 519 normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data, mentionedDoc?.uri), 520 + }; 521 + }) 522 + .filter((n) => n !== null); 523 + } 524 + 525 + export type HydratedRecommendNotification = Awaited< 526 + ReturnType<typeof hydrateRecommendNotifications> 527 + >[0]; 528 + 529 + async function hydrateRecommendNotifications(notifications: NotificationRow[]) { 530 + const recommendNotifications = notifications.filter( 531 + (n): n is NotificationRow & { data: ExtractNotificationType<"recommend"> } => 532 + (n.data as NotificationData)?.type === "recommend", 533 + ); 534 + 535 + if (recommendNotifications.length === 0) { 536 + return []; 537 + } 538 + 539 + // Fetch recommend data from the database 540 + const recommendUris = recommendNotifications.map((n) => n.data.recommend_uri); 541 + const documentUris = recommendNotifications.map((n) => n.data.document_uri); 542 + 543 + const [{ data: recommends }, { data: documents }] = await Promise.all([ 544 + supabaseServerClient 545 + .from("recommends_on_documents") 546 + .select("*, identities(bsky_profiles(*))") 547 + .in("uri", recommendUris), 548 + supabaseServerClient 549 + .from("documents") 550 + .select("*, documents_in_publications(publications(*))") 551 + .in("uri", documentUris), 552 + ]); 553 + 554 + return recommendNotifications 555 + .map((notification) => { 556 + const recommendData = recommends?.find((r) => r.uri === notification.data.recommend_uri); 557 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 558 + if (!recommendData || !document) return null; 559 + return { 560 + id: notification.id, 561 + recipient: notification.recipient, 562 + created_at: notification.created_at, 563 + type: "recommend" as const, 564 + recommend_uri: notification.data.recommend_uri, 565 + document_uri: notification.data.document_uri, 566 + recommendData, 567 + document, 568 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 569 + normalizedPublication: normalizePublicationRecord( 570 + document.documents_in_publications[0]?.publications?.record, 571 + ), 517 572 }; 518 573 }) 519 574 .filter((n) => n !== null);
+4
src/replicache/attributes.ts
··· 151 151 type: "string", 152 152 cardinality: "one", 153 153 }, 154 + "bluesky-post/host": { 155 + type: "string", 156 + cardinality: "one", 157 + }, 154 158 } as const; 155 159 156 160 const ButtonBlockAttributes = {
+14
src/replicache/cachedServerMutationContext.ts
··· 155 155 }); 156 156 }, 157 157 async retractFact(factID) { 158 + let cachedFact = writeCache.find( 159 + (f) => f.type === "put" && f.fact.id === factID, 160 + ); 161 + let entity: string | undefined; 162 + if (cachedFact && cachedFact.type === "put") { 163 + entity = cachedFact.fact.entity; 164 + } else { 165 + let [row] = await tx 166 + .select({ entity: facts.entity }) 167 + .from(facts) 168 + .where(driz.eq(facts.id, factID)); 169 + entity = row?.entity; 170 + } 171 + if (!entity || !(await this.checkPermission(entity))) return; 158 172 writeCache = writeCache.filter((f) => f.fact.id !== factID); 159 173 writeCache.push({ type: "del", fact: { id: factID } }); 160 174 },
+13 -1
src/replicache/mutations.ts
··· 659 659 tags?: string[]; 660 660 cover_image?: string | null; 661 661 localPublishedAt?: string | null; 662 + preferences?: { 663 + showComments?: boolean; 664 + showMentions?: boolean; 665 + showRecommends?: boolean; 666 + } | null; 662 667 }> = async (args, ctx) => { 663 668 await ctx.runOnServer(async (serverCtx) => { 664 - console.log("updating"); 665 669 const updates: { 666 670 description?: string; 667 671 title?: string; 668 672 tags?: string[]; 669 673 cover_image?: string | null; 674 + preferences?: { 675 + showComments?: boolean; 676 + showMentions?: boolean; 677 + showRecommends?: boolean; 678 + } | null; 670 679 } = {}; 671 680 if (args.description !== undefined) updates.description = args.description; 672 681 if (args.title !== undefined) updates.title = args.title; 673 682 if (args.tags !== undefined) updates.tags = args.tags; 674 683 if (args.cover_image !== undefined) updates.cover_image = args.cover_image; 684 + if (args.preferences !== undefined) updates.preferences = args.preferences; 675 685 676 686 if (Object.keys(updates).length > 0) { 677 687 // First try to update leaflets_in_publications (for publications) ··· 700 710 await tx.set("publication_cover_image", args.cover_image); 701 711 if (args.localPublishedAt !== undefined) 702 712 await tx.set("publication_local_published_at", args.localPublishedAt); 713 + if (args.preferences !== undefined) 714 + await tx.set("post_preferences", args.preferences); 703 715 }); 704 716 }; 705 717
+20
src/useSelectedPostState.ts
··· 1 + import { create } from "zustand"; 2 + import type { 3 + NormalizedDocument, 4 + NormalizedPublication, 5 + } from "src/utils/normalizeRecords"; 6 + 7 + export type SelectedPostListing = { 8 + document_uri: string; 9 + document: NormalizedDocument; 10 + publication?: NormalizedPublication; 11 + drawer: "quotes" | "comments"; 12 + }; 13 + 14 + export const useSelectedPostListing = create<{ 15 + selectedPostListing: SelectedPostListing | null; 16 + setSelectedPostListing: (post: SelectedPostListing | null) => void; 17 + }>((set) => ({ 18 + selectedPostListing: null, 19 + setSelectedPostListing: (post) => set({ selectedPostListing: post }), 20 + }));
+24 -13
src/utils/addLinkBlock.ts
··· 144 144 ) { 145 145 //construct bsky post uri from url 146 146 let urlParts = url?.split("/"); 147 + let host = urlParts ? urlParts[2] : "bsky.app"; // "bsky.app", "blacksky.community", "witchsky.app", etc. 147 148 let userDidOrHandle = urlParts ? urlParts[4] : ""; // "schlage.town" or "did:plc:jjsc5rflv3cpv6hgtqhn2dcm" 148 149 let collection = "app.bsky.feed.post"; 149 150 let postId = urlParts ? urlParts[6] : ""; ··· 152 153 let post = await getBlueskyPost(uri); 153 154 if (!post || post === undefined) return false; 154 155 155 - await rep.mutate.assertFact({ 156 - entity: entityID, 157 - attribute: "block/type", 158 - data: { type: "block-type-union", value: "bluesky-post" }, 159 - }); 160 - await rep?.mutate.assertFact({ 161 - entity: entityID, 162 - attribute: "block/bluesky-post", 163 - data: { 164 - type: "bluesky-post", 165 - //TODO: this is a hack to get rid of a nested Array buffer which cannot be frozen, which replicache does on write. 166 - value: JSON.parse(JSON.stringify(post.data.thread)), 156 + await rep.mutate.assertFact([ 157 + { 158 + entity: entityID, 159 + attribute: "block/type", 160 + data: { type: "block-type-union", value: "bluesky-post" }, 161 + }, 162 + { 163 + entity: entityID, 164 + attribute: "block/bluesky-post", 165 + data: { 166 + type: "bluesky-post", 167 + //TODO: this is a hack to get rid of a nested Array buffer which cannot be frozen, which replicache does on write. 168 + value: JSON.parse(JSON.stringify(post.data.thread)), 169 + }, 170 + }, 171 + { 172 + entity: entityID, 173 + attribute: "bluesky-post/host", 174 + data: { 175 + type: "string", 176 + value: host, 177 + }, 167 178 }, 168 - }); 179 + ]); 169 180 return true; 170 181 } 171 182 async function getBlueskyPost(uri: string) {
+10 -1
src/utils/getBlocksAsHTML.tsx
··· 79 79 mailbox: async () => null, 80 80 poll: async () => null, 81 81 embed: async () => null, 82 - "bluesky-post": async () => null, 82 + "bluesky-post": async (b, tx) => { 83 + let [post] = await scanIndex(tx).eav(b.value, "block/bluesky-post"); 84 + if (!post) return null; 85 + return ( 86 + <div 87 + data-type="bluesky-post" 88 + data-bluesky-post={JSON.stringify(post.data.value)} 89 + /> 90 + ); 91 + }, 83 92 math: async (b, tx, a) => { 84 93 let [math] = await scanIndex(tx).eav(b.value, "block/math"); 85 94 const html = Katex.renderToString(math?.data.value || "", {
+2 -5
src/utils/mentionUtils.ts
··· 19 19 const uri = new AtUri(atUri); 20 20 21 21 if (isPublicationCollection(uri.collection)) { 22 - // Publication URL: /lish/{did}/{rkey} 23 - return `/lish/${uri.host}/${uri.rkey}`; 22 + return `/lish/uri/${encodeURIComponent(atUri)}`; 24 23 } else if (isDocumentCollection(uri.collection)) { 25 - // Document URL - we need to resolve this via the API 26 - // For now, create a redirect route that will handle it 27 24 return `/lish/uri/${encodeURIComponent(atUri)}`; 28 25 } 29 26 ··· 42 39 export function handleMentionClick( 43 40 e: MouseEvent | React.MouseEvent, 44 41 type: "did" | "at-uri", 45 - value: string 42 + value: string, 46 43 ) { 47 44 e.preventDefault(); 48 45 e.stopPropagation();
+24
src/utils/mergePreferences.ts
··· 1 + type PreferencesInput = { 2 + showComments?: boolean; 3 + showMentions?: boolean; 4 + showRecommends?: boolean; 5 + showPrevNext?: boolean; 6 + } | null; 7 + 8 + export function mergePreferences( 9 + documentPrefs?: PreferencesInput, 10 + publicationPrefs?: PreferencesInput, 11 + ): { 12 + showComments?: boolean; 13 + showMentions?: boolean; 14 + showRecommends?: boolean; 15 + showPrevNext?: boolean; 16 + } { 17 + return { 18 + showComments: documentPrefs?.showComments ?? publicationPrefs?.showComments, 19 + showMentions: documentPrefs?.showMentions ?? publicationPrefs?.showMentions, 20 + showRecommends: 21 + documentPrefs?.showRecommends ?? publicationPrefs?.showRecommends, 22 + showPrevNext: publicationPrefs?.showPrevNext, 23 + }; 24 + }
+12
src/utils/useRecordFromDid.ts
··· 1 + import { callRPC } from "app/api/rpc/client"; 2 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3 + import useSWR from "swr"; 4 + 5 + export function useRecordFromDid(did: string | undefined | null) { 6 + return useSWR(did ? ["profile-data", did] : null, async () => { 7 + const response = await callRPC("get_profile_data", { 8 + didOrHandle: did!, 9 + }); 10 + return response.result.profile as ProfileViewDetailed | undefined; 11 + }); 12 + }
+17
supabase/database.types.ts
··· 335 335 } 336 336 documents: { 337 337 Row: { 338 + bsky_like_count: number 338 339 data: Json 340 + indexed: boolean 339 341 indexed_at: string 342 + recommend_count: number 340 343 sort_date: string 341 344 uri: string 342 345 } 343 346 Insert: { 347 + bsky_like_count?: number 344 348 data: Json 349 + indexed?: boolean 345 350 indexed_at?: string 351 + recommend_count?: number 352 + sort_date?: string 346 353 uri: string 347 354 } 348 355 Update: { 356 + bsky_like_count?: number 349 357 data?: Json 358 + indexed?: boolean 350 359 indexed_at?: string 360 + recommend_count?: number 361 + sort_date?: string 351 362 uri?: string 352 363 } 353 364 Relationships: [] ··· 589 600 description: string 590 601 doc: string | null 591 602 leaflet: string 603 + preferences: Json | null 592 604 publication: string 593 605 tags: string[] | null 594 606 title: string ··· 599 611 description?: string 600 612 doc?: string | null 601 613 leaflet: string 614 + preferences?: Json | null 602 615 publication: string 603 616 tags?: string[] | null 604 617 title?: string ··· 609 622 description?: string 610 623 doc?: string | null 611 624 leaflet?: string 625 + preferences?: Json | null 612 626 publication?: string 613 627 tags?: string[] | null 614 628 title?: string ··· 645 659 description: string 646 660 document: string 647 661 leaflet: string 662 + preferences: Json | null 648 663 tags: string[] | null 649 664 title: string 650 665 } ··· 655 670 description?: string 656 671 document: string 657 672 leaflet: string 673 + preferences?: Json | null 658 674 tags?: string[] | null 659 675 title?: string 660 676 } ··· 665 681 description?: string 666 682 document?: string 667 683 leaflet?: string 684 + preferences?: Json | null 668 685 tags?: string[] | null 669 686 title?: string 670 687 }
+2
supabase/migrations/20260208000000_add_preferences_to_drafts.sql
··· 1 + ALTER TABLE leaflets_in_publications ADD COLUMN preferences jsonb; 2 + ALTER TABLE leaflets_to_documents ADD COLUMN preferences jsonb;
+1
supabase/migrations/20260209000000_add_bsky_like_count.sql
··· 1 + ALTER TABLE documents ADD COLUMN bsky_like_count integer NOT NULL DEFAULT 0;
+23
supabase/migrations/20260210000000_add_recommend_count.sql
··· 1 + ALTER TABLE documents ADD COLUMN recommend_count integer NOT NULL DEFAULT 0; 2 + 3 + UPDATE documents d 4 + SET recommend_count = ( 5 + SELECT COUNT(*) FROM recommends_on_documents r WHERE r.document = d.uri 6 + ); 7 + 8 + CREATE OR REPLACE FUNCTION update_recommend_count() RETURNS trigger AS $$ 9 + BEGIN 10 + IF TG_OP = 'INSERT' THEN 11 + UPDATE documents SET recommend_count = recommend_count + 1 12 + WHERE uri = NEW.document; 13 + ELSIF TG_OP = 'DELETE' THEN 14 + UPDATE documents SET recommend_count = recommend_count - 1 15 + WHERE uri = OLD.document; 16 + END IF; 17 + RETURN NULL; 18 + END; 19 + $$ LANGUAGE plpgsql; 20 + 21 + CREATE TRIGGER trg_recommend_count 22 + AFTER INSERT OR DELETE ON recommends_on_documents 23 + FOR EACH ROW EXECUTE FUNCTION update_recommend_count();
+6
supabase/migrations/20260210100000_add_indexed_column_and_ranking_index.sql
··· 1 + ALTER TABLE documents ADD COLUMN indexed boolean NOT NULL DEFAULT true; 2 + 3 + CREATE INDEX idx_documents_ranking 4 + ON documents (sort_date DESC) 5 + INCLUDE (uri, bsky_like_count, recommend_count) 6 + WHERE indexed = true;
+1
supabase/migrations/20260211000000_indexed_default_false.sql
··· 1 + ALTER TABLE documents ALTER COLUMN indexed SET DEFAULT false;