diff --git a/CLAUDE.md b/CLAUDE.md index 2317a3fd..dd3521e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,14 @@ Documents are composed of **blocks** (text, image, embed, code, poll, etc.). Blo - **Appview** (`appview/`) consumes the firehose to index published content - **Feeds** (`feeds/`) provides subscription feeds for publications +### Lexicon Workflow + +The source of truth for lexicon schemas is the TypeScript files in `lexicons/src/` (e.g. `facet.ts`, `publication.ts`, `blocks.ts`). The JSON files in `lexicons/pub/` are **generated output** — do not edit them directly, as `npm run lexgen` will overwrite them. + +To add or modify a lexicon: +1. Edit the relevant source file in `lexicons/src/` +2. Run `npm run lexgen` to regenerate both the JSON schemas and the TypeScript API types in `lexicons/api/` + ### Key Directories - `app/` - Next.js App Router pages and API routes @@ -62,3 +70,4 @@ Documents are composed of **blocks** (text, image, embed, code, poll, etc.). Blo - **Replicache mutations**: Named handlers in `src/replicache/mutations.ts`, keep server mutations idempotent - **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data - **Inngest functions**: Async jobs in `app/api/inngest/functions/` +- **Icons**: Icon components live in `components/Icons/`. Each icon is a named export in its own file (e.g. `RefreshSmall.tsx`), imports `Props` from `./Props`, spreads `{...props}` on the `` element, and uses `fill="currentColor"` instead of hardcoded colors like `fill="black"`. diff --git a/app/[leaflet_id]/actions/HelpButton.tsx b/app/[leaflet_id]/actions/HelpButton.tsx index aeab5ce5..aab289ce 100644 --- a/app/[leaflet_id]/actions/HelpButton.tsx +++ b/app/[leaflet_id]/actions/HelpButton.tsx @@ -30,7 +30,7 @@ export const HelpButton = (props: { noShortcuts?: boolean }) => { {/* contact links */} diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index 6ca18b02..c2497139 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -190,7 +190,7 @@ const PublishToPublicationButton = (props: { entityID: string }) => { } - label={"Publish on ATP"} + label={"Publish"} /> } > diff --git a/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx index 4a856f03..fb3042d9 100644 --- a/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx +++ b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx @@ -47,7 +47,7 @@ export function CustomDomainMenu(props: { ); case "domain-settings": return ( -
+
setState({ state: "default" })} @@ -58,14 +58,12 @@ export function CustomDomainMenu(props: { ); case "add-domain": return ( -
- - setState({ state: "domain-settings", domain }) - } - onBack={() => setState({ state: "default" })} - /> -
+ + setState({ state: "domain-settings", domain }) + } + onBack={() => setState({ state: "default" })} + /> ); } } @@ -118,8 +116,7 @@ const DomainOptions = (props: { let route = "/" + selectedRoute; return domain.custom_domain_routes.some( (r) => - r.route === route && - r.edit_permission_token !== permission_token.id, + r.route === route && r.edit_permission_token !== permission_token.id, ); })(); @@ -202,10 +199,9 @@ const DomainOptions = (props: { (dd) => dd.domain === domain.domain, ); if (d) { - d.custom_domain_routes = - d.custom_domain_routes.filter( - (r) => r.id !== routeId, - ); + d.custom_domain_routes = d.custom_domain_routes.filter( + (r) => r.id !== routeId, + ); } }); mutateDomains(); diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 7c443496..118292cb 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -262,10 +262,10 @@ const SubscribeSuccessModal = ({ - + { > {loading ? : "Get it!"} - {error && ( -
{error}
- )} + {error &&
{error}
}
@@ -91,3 +89,21 @@ export const UpgradeModal = (props: { ); }; + +export const InlineUpgrade = () => { + return ( +
+ + Upgrade to Leaflet Pro! + + } + /> +
+ Analytics for all your pubs!
Emails and membership coming soon. +
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 44394231..1c8f1b19 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -34,6 +34,7 @@ import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; import { PollData } from "./fetchPollData"; import { ButtonPrimary } from "components/Buttons"; import { blockTextSize } from "src/utils/blockTextSize"; +import { slugify } from "src/utils/slugify"; import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; export function PostContent({ @@ -391,52 +392,48 @@ export let Block = ({ ); case PubLeafletBlocksHeader.isMain(b.block): { + let slug = slugify(b.block.plaintext); + let headingProps = { + ...blockProps, + id: preview ? undefined : slug || blockProps.id, + }; + let textBlockProps = { + ...b.block, + index, + preview, + pageId, + footnoteIndexMap, + }; + let href = pageId ? `?page=${pageId}#${slug}` : `#${slug}`; + let link = (children: React.ReactNode) => + preview || !slug ? ( + children + ) : ( + + {children} + + ); if (b.block.level === 1) return ( -

- +

+ {link()}

); if (b.block.level === 2) return ( -

- +

+ {link()}

); if (b.block.level === 3) return ( -

- +

+ {link()}

); - // if (b.block.level === 4) return

{b.block.plaintext}

; - // if (b.block.level === 5) return
{b.block.plaintext}
; return ( -
- +
+ {link()}
); } @@ -472,25 +469,24 @@ function ListItem(props: { ))} ) : null; - let orderedChildren = - props.item.orderedListChildren?.children?.length ? ( -
    - {props.item.orderedListChildren.children.map((child, index) => ( - - ))} -
- ) : null; + let orderedChildren = props.item.orderedListChildren?.children?.length ? ( +
    + {props.item.orderedListChildren.children.map((child, index) => ( + + ))} +
+ ) : null; return (
  • {props.item.children.map((child, index) => ( @@ -544,27 +541,26 @@ function OrderedListItem(props: { ))} ) : null; - let unorderedChildren = - props.item.unorderedListChildren?.children?.length ? ( -
      - {props.item.unorderedListChildren.children.map((child, index) => ( - - ))} -
    - ) : null; + let unorderedChildren = props.item.unorderedListChildren?.children?.length ? ( +
      + {props.item.unorderedListChildren.children.map((child, index) => ( + + ))} +
    + ) : null; return (
  • -
    +
    {calculatedIndex}.
    diff --git a/app/lish/[did]/[publication]/dashboard/Actions.tsx b/app/lish/[did]/[publication]/dashboard/Actions.tsx index bac897c7..f6c8b01c 100644 --- a/app/lish/[did]/[publication]/dashboard/Actions.tsx +++ b/app/lish/[did]/[publication]/dashboard/Actions.tsx @@ -21,8 +21,6 @@ export const Actions = (props: { publication: string }) => { return ( <> - {canSeePro && !isPro && } - @@ -85,19 +83,3 @@ function PublicationShareButton() { ); } - -const MobileUpgrade = () => { - return ( - } - className={`sm:hidden block bg-[var(--accent-light)]!`} - style={{ backgroundColor: "var(--accent-light) important!" }} - /> - } - /> - ); -}; diff --git a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx index b1c437f3..7e84ed17 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx @@ -241,9 +241,9 @@ export const PublicationAnalytics = (props: { if (!isPro) return (
    )}
    - +
    {formatDayTick(String(label))}
    -
    {Number(pageviews).toLocaleString()} {metricLabel}
    +
    + {Number(pageviews).toLocaleString()} {metricLabel} +
    ); }} diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx index aede5c80..0ccd083b 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx @@ -48,7 +48,7 @@ export function PublicationSubscribers(props: { if (subscribers.length === 0) return (
    void }) => { +export const ManageProSubscription = (props: {}) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { identity } = useIdentityData(); @@ -34,9 +34,6 @@ export const ManageProSubscription = (props: { backToMenu: () => void }) => {
    Manage Subscription -
    diff --git a/app/lish/createPub/CreatePubForm.tsx b/app/lish/createPub/CreatePubForm.tsx index 749b4397..c6138dac 100644 --- a/app/lish/createPub/CreatePubForm.tsx +++ b/app/lish/createPub/CreatePubForm.tsx @@ -151,11 +151,11 @@ export const CreatePubForm = () => {

    Show In Discover

    - Your posts will appear on our{" "} - - Discover - {" "} - page. You can change this at any time! + Your posts will appear in{" "} + + Leaflet Reader + + . You can change this at any time!

    diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index b7a573fa..28464c4b 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -165,18 +165,13 @@ export const EditPubForm = (props: { onToggle={() => setShowInDiscover(!showInDiscover)} >
    -

    - Show In{" "} - - Discover - -

    +

    Show In Discover

    - Your posts will appear on our{" "} - - Discover - {" "} - page. You can change this at any time! + Your posts will appear in{" "} + + Leaflet Reader + + . You can change this at any time!

    diff --git a/components/ActionBar/ProfileButton.tsx b/components/ActionBar/ProfileButton.tsx index cf844c82..be888213 100644 --- a/components/ActionBar/ProfileButton.tsx +++ b/components/ActionBar/ProfileButton.tsx @@ -8,22 +8,19 @@ import { LogoutSmall } from "components/Icons/LogoutSmall"; import { mutate } from "swr"; import { SpeedyLink } from "components/SpeedyLink"; import { Popover } from "components/Popover"; -import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { Modal } from "components/Modal"; -import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; +import { InlineUpgrade } from "app/lish/[did]/[publication]/UpgradeModal"; import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; import { ManageDomains } from "components/Domains/ManageDomains"; import { WebSmall } from "components/Icons/WebSmall"; import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; -import { useState } from "react"; +import { LeafletPro } from "components/Icons/LeafletPro"; +import { ButtonPrimary } from "components/Buttons"; export const ProfileButton = () => { let { identity } = useIdentityData(); let { data: record } = useRecordFromDid(identity?.atp_did); let isMobile = useIsMobile(); - let [state, setState] = useState< - "menu" | "manage-subscription" | "manage-domains" - >("menu"); let isPro = useIsPro(); let canSeePro = useCanSeePro(); @@ -32,8 +29,7 @@ export const ProfileButton = () => { asChild side={isMobile ? "top" : "right"} align={isMobile ? "center" : "start"} - onOpenChange={() => setState("menu")} - className="w-xs" + className="w-xs py-1!" trigger={ { /> } > - {state === "manage-subscription" ? ( - setState("menu")} /> - ) : state === "manage-domains" ? ( - setState("menu")} /> - ) : ( -
    - {record && ( - <> - - - +
    + {record && ( + <> + + + + + )} -
    - - )} - {canSeePro && ( - <> - {!isPro ? ( - - Get Leaflet Pro - -
    - } - > - - - ) : ( - - )} - -
    - - )} - - + +
    -
    - - -
    - )} + {canSeePro && isPro && ( + <> + + + Manage Pro Subscription +
    + } + > + + +
    + + )} + + {canSeePro && !isPro && ( + <> + {" "} +
    +
    + +
    + + )} +
    ); }; diff --git a/components/Buttons.tsx b/components/Buttons.tsx index 966c2e9e..ad1587b7 100644 --- a/components/Buttons.tsx +++ b/components/Buttons.tsx @@ -37,7 +37,7 @@ export const ButtonPrimary = forwardRef< ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} ${compact ? "py-0 px-1" : "px-2 py-0.5 "} bg-accent-1 disabled:bg-border-light - border border-accent-1 rounded-md disabled:border-border-light + border border-accent-1 rounded-md disabled:border-border-light disabled:outline-none! disabled:cursor-not-allowed! outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border flex gap-2 items-center justify-center shrink-0 @@ -77,7 +77,7 @@ export const ButtonSecondary = forwardRef< ${compact ? "py-0 px-1" : "px-2 py-0.5 "} bg-bg-page disabled:bg-border-light border border-accent-contrast rounded-md - outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 + outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 disabled:outline-none! disabled:cursor-not-allowed! text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border flex gap-2 items-center justify-center shrink-0 ${props.className} @@ -116,7 +116,7 @@ export const ButtonTertiary = forwardRef< ${compact ? "py-0 px-1" : "px-2 py-0.5 "} bg-transparent hover:bg-[var(--accent-light)] border border-transparent rounded-md hover:border-[var(--accent-light)] - outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 + outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 disabled:outline-none! disabled:cursor-not-allowed! text-base font-bold text-accent-contrast disabled:text-border flex gap-2 items-center justify-center shrink-0 ${props.className} diff --git a/components/Domains/AddDomainForm.tsx b/components/Domains/AddDomainForm.tsx index 3e72e652..cf1d3720 100644 --- a/components/Domains/AddDomainForm.tsx +++ b/components/Domains/AddDomainForm.tsx @@ -18,11 +18,12 @@ export function AddDomainForm(props: { return (
    -
    -

    Add a New Domain

    -
    - Don't include the protocol or path, just the base domain name -
    +

    Add a Domain

    +
    +
    Just include the base domain
    + Don't include the protocol{" "} + (like https://) + or path (you can add that later)
    ["custom_domains"][number]; export function DomainList(props: { onSelectDomain: (domain: string) => void; - onAddDomain: () => void; filter?: (domain: CustomDomain) => boolean; }) { let { identity } = useIdentityData(); @@ -79,13 +78,6 @@ export function DomainList(props: {
    )} -
    ); } diff --git a/components/Domains/DomainSettingsView.tsx b/components/Domains/DomainSettingsView.tsx index dda6e99f..c3f3b884 100644 --- a/components/Domains/DomainSettingsView.tsx +++ b/components/Domains/DomainSettingsView.tsx @@ -9,8 +9,10 @@ import { useIdentityData, mutateIdentityData, } from "components/IdentityProvider"; -import { ButtonPrimary } from "components/Buttons"; +import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; import { getDomainAssignment } from "./domainAssignment"; +import { RefreshSmall } from "components/Icons/RefreshSmall"; +import { GoToArrow } from "components/Icons/GoToArrow"; export function DomainSettingsView(props: { domain: string; @@ -34,7 +36,19 @@ export function DomainSettingsView(props: { return (
    -

    {props.domain}

    +
    +

    + {needsSetup ? `Verify ${props.domain}` : props.domain} +

    + + {needsSetup && mutateDomainStatus()} />} +
    {needsSetup ? ( <> @@ -42,26 +56,26 @@ export function DomainSettingsView(props: { To verify this domain, add the following record to your DNS provider for {props.domain}.
    - +
    - - - + + + {data?.verification && ( - - - - - - ) : ( - - -
    TypeNameValueTypeNameValue
    +
    {data.verification[0].type}
    +
    {data.verification[0].domain}
    +
    {data.verification[0].value}
    @@ -71,15 +85,15 @@ export function DomainSettingsView(props: { {data?.config && (isSubdomain ? (
    +
    CNAME
    +
    {props.domain.split(".").slice(0, -2).join(".")}
    +
    { data.config.recommendedCNAME.sort( @@ -91,13 +105,13 @@ export function DomainSettingsView(props: {
    +
    A
    +
    @
    +
    { data.config.recommendedIPv4.sort( @@ -181,21 +195,6 @@ export function DomainSettingsView(props: { )}
    -
    - -
    - {needsSetup && ( - mutateDomainStatus()} /> - )} -
    -
    -
    - -
    + setConfirming(true)} + > + Delete Domain + ); } return ( -
    -

    +

    +

    Are you sure you want to delete {props.domain}? This will remove all assignments and cannot be undone.

    -
    +
    { setLoading(true); @@ -270,8 +269,9 @@ function DeleteDomainButton(props: { function VerifyButton(props: { verify: () => Promise }) { let [loading, setLoading] = useState(false); return ( - + {loading ? : "Check Status"} + ); } diff --git a/components/Domains/ManageDomains.tsx b/components/Domains/ManageDomains.tsx index 262828f8..9acd65bb 100644 --- a/components/Domains/ManageDomains.tsx +++ b/components/Domains/ManageDomains.tsx @@ -1,64 +1,58 @@ "use client"; import { useState } from "react"; -import { GoBackSmall } from "components/Icons/GoBackSmall"; import { DomainList } from "./DomainList"; import { AddDomainForm } from "./AddDomainForm"; import { DomainSettingsView } from "./DomainSettingsView"; -import { GoToArrow } from "components/Icons/GoToArrow"; +import { Modal } from "components/Modal"; +import { ButtonPrimary } from "components/Buttons"; +import { WebSmall } from "components/Icons/WebSmall"; type State = | "list" | "add-domain" | { type: "domain-settings"; domain: string }; -export function ManageDomains(props: { backToMenu: () => void }) { +export function ManageDomains() { let [state, setState] = useState("list"); - - if (state === "add-domain") { - return ( -
    + return ( + + + Domain Settings +
    + } + > + {state === "add-domain" ? ( setState({ type: "domain-settings", domain }) } onBack={() => setState("list")} /> -
    - ); - } - - if (typeof state === "object" && state.type === "domain-settings") { - return ( -
    + ) : typeof state === "object" && state.type === "domain-settings" ? ( setState("list")} onRemoveAssignment={() => setState("list")} onDeleteDomain={() => setState("list")} /> -
    - ); - } - - return ( -
    -
    -

    Connected Domains

    - -
    -
    - - setState({ type: "domain-settings", domain }) - } - onAddDomain={() => setState("add-domain")} - /> -
    + ) : ( +
    +
    +

    Domains

    + setState("add-domain")}> + Add + +
    + + setState({ type: "domain-settings", domain }) + } + /> +
    + )} + ); } diff --git a/components/Icons/RefreshSmall.tsx b/components/Icons/RefreshSmall.tsx new file mode 100644 index 00000000..21d11595 --- /dev/null +++ b/components/Icons/RefreshSmall.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const RefreshSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Modal.tsx b/components/Modal.tsx index fa9def2e..09fdbe40 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -23,11 +23,11 @@ export const Modal = ({ {trigger} - + @@ -44,10 +44,10 @@ export const Modal = ({

    {title}

    - ) : ( - - )} - {children} + ) : null} + +
    {children}
    +
    diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx index 0ab1677f..9a28fcf7 100644 --- a/components/PageLayouts/DashboardLayout.tsx +++ b/components/PageLayouts/DashboardLayout.tsx @@ -304,7 +304,7 @@ export const HomeDashboardControls = (props: { - {props.hasPubs ? ( + {props.hasPubs || props.hasArchived ? ( <> } - title="Welcome to Pro" - className="w-80" + className="sm:max-w-sm sm:w-[1000px] w-full" > {loading ? (
    @@ -50,12 +51,43 @@ export function SubscriptionSuccessModal() {

    ) : ( -
    -

    - Your Pro subscription is active. Thanks for supporting Leaflet! +

    +
    + +
    +

    Welcome to Pro!

    +

    + Thank you for supporting Leaflet. +
    + We're looking forward to bringing you even cooler stuff in the near + future!

    + + Got it, Thanks! +
    )} ); } + +const ProIllo = () => { + return ( + + + + + ); +}; diff --git a/lexicons/src/facet.ts b/lexicons/src/facet.ts index 93d4c416..b942df88 100644 --- a/lexicons/src/facet.ts +++ b/lexicons/src/facet.ts @@ -64,6 +64,19 @@ const FacetItems: LexiconDoc["defs"] = { required: [], properties: {}, }, + footnote: { + type: "object", + description: "Facet feature for a footnote reference", + required: ["footnoteId", "contentPlaintext"], + properties: { + footnoteId: { type: "string" }, + contentPlaintext: { type: "string" }, + contentFacets: { + type: "array", + items: { type: "ref", ref: "#main" }, + }, + }, + }, }; export const PubLeafletRichTextFacet = { diff --git a/lib/tinybird.ts b/lib/tinybird.ts index d7211a9a..9ccac361 100644 --- a/lib/tinybird.ts +++ b/lib/tinybird.ts @@ -94,6 +94,7 @@ export const publicationTraffic = defineEndpoint("publication_traffic", { date_from: p.string().optional(), date_to: p.string().optional(), path: p.string().optional(), + referrer_host: p.string().optional(), }, tokens: [PROD_READ_TOKEN], nodes: [ @@ -116,6 +117,9 @@ export const publicationTraffic = defineEndpoint("publication_traffic", { {% if defined(path) %} AND path = {{String(path)}} {% end %} + {% if defined(referrer_host) %} + AND domain(referrer) = {{String(referrer_host)}} + {% end %} GROUP BY day ORDER BY day ASC `, @@ -146,6 +150,7 @@ export const publicationTopReferrers = defineEndpoint( date_from: p.string().optional(), date_to: p.string().optional(), path: p.string().optional(), + referrer_host: p.string().optional(), limit: p.int32().optional(10), }, nodes: [ @@ -169,6 +174,9 @@ export const publicationTopReferrers = defineEndpoint( {% if defined(path) %} AND path = {{String(path)}} {% end %} + {% if defined(referrer_host) %} + AND domain(referrer) = {{String(referrer_host)}} + {% end %} GROUP BY referrer_host ORDER BY pageviews DESC LIMIT {{Int32(limit, 10)}} @@ -199,6 +207,7 @@ export const publicationTopPages = defineEndpoint("publication_top_pages", { domains: p.string(), date_from: p.string().optional(), date_to: p.string().optional(), + referrer_host: p.string().optional(), limit: p.int32().optional(10), }, nodes: [ @@ -217,6 +226,9 @@ export const publicationTopPages = defineEndpoint("publication_top_pages", { {% if defined(date_to) %} AND fromUnixTimestamp64Milli(timestamp) <= parseDateTimeBestEffort({{String(date_to)}}) {% end %} + {% if defined(referrer_host) %} + AND domain(referrer) = {{String(referrer_host)}} + {% end %} GROUP BY path ORDER BY pageviews DESC LIMIT {{Int32(limit, 10)}} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/utils/addImage.ts b/src/utils/addImage.ts index 86f26ed4..84224035 100644 --- a/src/utils/addImage.ts +++ b/src/utils/addImage.ts @@ -19,13 +19,17 @@ export async function addImage( let fileID = v7(); let url = client.storage.from("minilink-user-assets").getPublicUrl(fileID) .data.publicUrl; - let dimensions = await getImageDimensions(file); + // Re-encode through canvas to bake EXIF orientation into pixel data. + // iPhone photos have EXIF rotation metadata that browsers respect, but + // Supabase's image transformation pipeline strips without applying. + let { blob: uploadBlob, width, height } = await normalizeOrientation(file); + await cache.put( new URL(url + "?local"), - new Response(file, { + new Response(uploadBlob, { headers: { - "Content-Type": file.type, - "Content-Length": file.size.toString(), + "Content-Type": uploadBlob.type, + "Content-Length": uploadBlob.size.toString(), }, }), ); @@ -41,11 +45,11 @@ export async function addImage( type: "image", local: rep.clientID, src: url, - height: dimensions.height, - width: dimensions.width, + height, + width, }, }); - await client.storage.from("minilink-user-assets").upload(fileID, file, { + await client.storage.from("minilink-user-assets").upload(fileID, uploadBlob, { cacheControl: "public, max-age=31560000, immutable", }); await rep.mutate.assertFact({ @@ -55,8 +59,8 @@ export async function addImage( fallback: thumbhash, type: "image", src: url, - height: dimensions.height, - width: dimensions.width, + height, + width, }, }); } @@ -95,17 +99,18 @@ async function getThumbHash(file: File) { return thumbHash; } -function getImageDimensions( +async function normalizeOrientation( file: File, -): Promise<{ width: number; height: number }> { - let url = URL.createObjectURL(file); - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = function () { - resolve({ width: img.width, height: img.height }); - URL.revokeObjectURL(url); - }; - img.onerror = reject; - img.src = url; - }); +): Promise<{ blob: Blob; width: number; height: number }> { + let bitmap = await createImageBitmap(file); + let canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + let ctx = canvas.getContext("2d")!; + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + let blob = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b!), "image/webp", 0.92), + ); + return { blob, width: canvas.width, height: canvas.height }; } diff --git a/src/utils/slugify.ts b/src/utils/slugify.ts new file mode 100644 index 00000000..2876a38f --- /dev/null +++ b/src/utils/slugify.ts @@ -0,0 +1,9 @@ +export function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} diff --git a/supabase/migrations/20260310000000_fix_search_tags_object_error.sql b/supabase/migrations/20260310000000_fix_search_tags_object_error.sql new file mode 100644 index 00000000..1c5368a5 --- /dev/null +++ b/supabase/migrations/20260310000000_fix_search_tags_object_error.sql @@ -0,0 +1,28 @@ +-- Fix search_tags function to handle documents where data->'tags' is not a JSON array +-- This prevents "cannot extract elements from an object" errors +CREATE OR REPLACE FUNCTION search_tags(search_query text) +RETURNS TABLE (name text, document_count bigint) AS $$ +BEGIN + RETURN QUERY + SELECT + LOWER(tag::text) as name, + COUNT(DISTINCT d.uri) as document_count + FROM ( + SELECT uri, data + FROM "public"."documents" + WHERE jsonb_typeof(data->'tags') = 'array' + ) d, + jsonb_array_elements_text(d.data->'tags') as tag + WHERE + CASE + WHEN search_query = '' THEN true + ELSE LOWER(tag::text) LIKE '%' || search_query || '%' + END + GROUP BY + LOWER(tag::text) + ORDER BY + COUNT(DISTINCT d.uri) DESC, + LOWER(tag::text) ASC + LIMIT 20; +END; +$$ LANGUAGE plpgsql STABLE;