From 4d4269a8a763d20946aff5338f51c8a8d8d57fb8 Mon Sep 17 00:00:00 2001 From: sarah <129242944+sarah-inkeep@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:17:34 -0700 Subject: [PATCH] feat(open-knowledge): in-app product-updates subscription (PRD-7196) (#2273) * feat(open-knowledge): in-app product-updates subscription (PRD-7196) Regroup the Resources menu into Resources, Community, and Product updates. Add a Subscribe action that opens a reusable email form (shadcn + react-hook-form) posting to the docs /api/subscribe endpoint, plus a live GitHub star count on the GitHub row. Add CORS to the subscribe route so the desktop and web app can call it cross-origin. * refactor(open-knowledge): share getGitHubStars from core Hoist the docs site-nav star fetcher into @inkeep/open-knowledge-core so the Resources menu reuses it instead of duplicating the GitHub API call. An optional RequestInit lets the docs site keep its hourly server-side revalidate while the editor app passes an abort signal. * fix(open-knowledge): address review feedback on subscribe + star fetch - subscribe: add a 15s request timeout so a hung connection can't leave the Submit button spinning forever; log HTTP error responses client-side. - getGitHubStars: compose a caller-supplied signal with the 5s timeout via AbortSignal.any instead of letting it overwrite the timeout. - HelpPopover: reset the nested Subscribe popover when the menu closes. - SubscribeForm: make the live region's role="status" unconditional so the success announcement isn't missed (WCAG 4.1.3). * fix(open-knowledge): a11y + test coverage from subscribe re-review - SubscribeForm: give the Submit button an accessible name while submitting (sr-only "Subscribing..." alongside the aria-hidden spinner). - SubscribeForm: move focus to the confirmation heading after success so keyboard/SR focus isn't orphaned when the form is replaced. - Test the reason: 'invalid' and 'unavailable' submit branches, which have distinct UI from the generic 'error' branch. * feat(open-knowledge): allow dev override of the subscribe endpoint Read VITE_SUBSCRIBE_ENDPOINT (defaulting to the hosted docs endpoint) so the in-app Subscribe form can be pointed at a local docs server to exercise the cross-origin POST + CORS path without hitting production. * revert(open-knowledge): drop VITE_SUBSCRIBE_ENDPOINT override; subscribe form tweaks The env override didn't take effect in the desktop/turbo path and isn't worth the complexity, so the form posts to the hosted endpoint unconditionally again. Also folds in success-view copy tweaks (checkmark + "You're subscribed!") and fixes invalid
-in-

nesting in the success heading; i18n regenerated. * fix(open-knowledge): use Megaphone for What's new (reserve Sparkles for AI) GitOrigin-RevId: 607980535023debe43e3e9b950feaff199b4a634 --- .changeset/subscribe-product-updates.md | 5 + docs/src/app/(home)/layout.tsx | 4 +- docs/src/app/api/subscribe/route.ts | 27 ++- .../src/components/HelpPopover.dom.test.tsx | 109 +++++++--- packages/app/src/components/HelpPopover.tsx | 192 ++++++++++++++---- .../src/components/SubscribeForm.dom.test.tsx | 88 ++++++++ packages/app/src/components/SubscribeForm.tsx | 172 ++++++++++++++++ packages/app/src/lib/subscribe.ts | 34 ++++ packages/app/src/locales/en/messages.json | 17 ++ packages/app/src/locales/en/messages.po | 70 +++++++ packages/app/src/locales/pseudo/messages.json | 17 ++ packages/app/src/locales/pseudo/messages.po | 70 +++++++ packages/core/src/index.ts | 1 + .../core/src/utils}/github-stars.ts | 12 +- 14 files changed, 738 insertions(+), 80 deletions(-) create mode 100644 .changeset/subscribe-product-updates.md create mode 100644 packages/app/src/components/SubscribeForm.dom.test.tsx create mode 100644 packages/app/src/components/SubscribeForm.tsx create mode 100644 packages/app/src/lib/subscribe.ts rename {docs/src/lib => packages/core/src/utils}/github-stars.ts (64%) diff --git a/.changeset/subscribe-product-updates.md b/.changeset/subscribe-product-updates.md new file mode 100644 index 000000000..2bd330544 --- /dev/null +++ b/.changeset/subscribe-product-updates.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": minor +--- + +Add an in-app product-updates subscription. The Resources menu is regrouped into Resources, Community, and Product updates, and a new Subscribe action opens a reusable email form (shadcn + react-hook-form) that registers the address through the `/api/subscribe` endpoint. The GitHub row now shows the repo's live star count. diff --git a/docs/src/app/(home)/layout.tsx b/docs/src/app/(home)/layout.tsx index d88cfe761..d252fb144 100644 --- a/docs/src/app/(home)/layout.tsx +++ b/docs/src/app/(home)/layout.tsx @@ -1,8 +1,8 @@ -import { getGitHubStars } from '@/lib/github-stars'; +import { getGitHubStars } from '@inkeep/open-knowledge-core'; import { SiteNav } from './site-nav'; export default async function Layout({ children }: LayoutProps<'/'>) { - const stars = await getGitHubStars(); + const stars = await getGitHubStars({ next: { revalidate: 3600 } }); return ( <> diff --git a/docs/src/app/api/subscribe/route.ts b/docs/src/app/api/subscribe/route.ts index daf62ad3d..9ebf0705d 100644 --- a/docs/src/app/api/subscribe/route.ts +++ b/docs/src/app/api/subscribe/route.ts @@ -11,25 +11,36 @@ const subscribeSchema = z.object({ email: z.email(), }); +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'content-type', +} as const; + +function json(body: unknown, status: number): NextResponse { + return NextResponse.json(body, { status, headers: CORS_HEADERS }); +} + +export function OPTIONS(): NextResponse { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} + export async function POST(request: Request): Promise { if (!RESEND_API_KEY || !RESEND_SEGMENT_ID) { console.error('[subscribe] RESEND_API_KEY or RESEND_SEGMENT_ID is not configured'); - return NextResponse.json( - { error: 'Subscriptions are not available right now.' }, - { status: 503 }, - ); + return json({ error: 'Subscriptions are not available right now.' }, 503); } let payload: unknown; try { payload = await request.json(); } catch { - return NextResponse.json({ error: 'Enter a valid email address.' }, { status: 400 }); + return json({ error: 'Enter a valid email address.' }, 400); } const parsed = subscribeSchema.safeParse(payload); if (!parsed.success) { - return NextResponse.json({ error: 'Enter a valid email address.' }, { status: 400 }); + return json({ error: 'Enter a valid email address.' }, 400); } const resend = new Resend(RESEND_API_KEY); @@ -41,8 +52,8 @@ export async function POST(request: Request): Promise { if (error) { console.error(`[subscribe] Resend create-contact failed: ${error.name} - ${error.message}`); - return NextResponse.json({ error: 'Something went wrong. Please try again.' }, { status: 502 }); + return json({ error: 'Something went wrong. Please try again.' }, 502); } - return NextResponse.json({ ok: true }); + return json({ ok: true }, 200); } diff --git a/packages/app/src/components/HelpPopover.dom.test.tsx b/packages/app/src/components/HelpPopover.dom.test.tsx index 53da8d75d..244dd5674 100644 --- a/packages/app/src/components/HelpPopover.dom.test.tsx +++ b/packages/app/src/components/HelpPopover.dom.test.tsx @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test'; -import { cleanup, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ReactNode } from 'react'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -28,44 +28,59 @@ async function renderOpenHelpPopover() { await userEvent.click(screen.getByRole('button', { name: 'Resources' })); } +function linkShape(link: HTMLElement) { + return { + label: Array.from(link.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent) + .join('') + .trim(), + href: link.getAttribute('href'), + target: link.getAttribute('target'), + rel: link.getAttribute('rel'), + hasIcon: link.querySelector('svg') !== null, + }; +} + +const originalFetch = globalThis.fetch; + describe('HelpPopover runtime behavior', () => { - afterEach(() => cleanup()); + beforeEach(() => { + globalThis.fetch = mock( + async () => + new Response(JSON.stringify({ stargazers_count: 1234 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) as typeof globalThis.fetch; + }); + + afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + }); test('exports the component', async () => { const mod = await import('./HelpPopover'); expect(typeof mod.HelpPopover).toBe('function'); }); - test('opens a resources-only nav whose accessible names match the visible heading', async () => { + test('groups links under Resources, Community, and Product updates navs', async () => { await renderOpenHelpPopover(); - expect(screen.getAllByText('Resources').length).toBeGreaterThanOrEqual(2); - const nav = screen.getByRole('navigation', { name: 'Resources' }); - expect(nav).not.toBeNull(); + for (const heading of ['Resources', 'Community', 'Product updates']) { + expect(screen.getByRole('navigation', { name: heading })).not.toBeNull(); + } expect(screen.queryByText(/Help\s*&\s*Resources/i)).toBeNull(); - expect(screen.queryByText('Setup')).toBeNull(); expect(screen.queryByText('Settings')).toBeNull(); - expect(screen.queryByText('Install for Claude Chat')).toBeNull(); }); - test('renders the external resource links in the required order', async () => { + test('renders Resources links in the required order', async () => { await renderOpenHelpPopover(); const nav = screen.getByRole('navigation', { name: 'Resources' }); const links = within(nav).getAllByRole('link'); - expect( - links.map((link) => ({ - label: Array.from(link.childNodes) - .filter((node) => node.nodeType === Node.TEXT_NODE) - .map((node) => node.textContent) - .join('') - .trim(), - href: link.getAttribute('href'), - target: link.getAttribute('target'), - rel: link.getAttribute('rel'), - hasIcon: link.querySelector('svg') !== null, - })), - ).toEqual([ + expect(links.map(linkShape)).toEqual([ { label: 'Docs', href: 'https://openknowledge.ai/docs', @@ -74,12 +89,28 @@ describe('HelpPopover runtime behavior', () => { hasIcon: true, }, { - label: 'GitHub', - href: 'https://github.com/inkeep/open-knowledge', + label: 'File an issue', + href: 'https://github.com/inkeep/open-knowledge/issues/new', target: '_blank', rel: 'noopener noreferrer', hasIcon: true, }, + { + label: 'Website', + href: 'https://openknowledge.ai/', + target: '_blank', + rel: 'noopener noreferrer', + hasIcon: true, + }, + ]); + }); + + test('renders Community links in the required order', async () => { + await renderOpenHelpPopover(); + + const nav = screen.getByRole('navigation', { name: 'Community' }); + const links = within(nav).getAllByRole('link'); + expect(links.map(linkShape)).toEqual([ { label: 'Discord', href: 'https://discord.com/invite/YujKpFN49', @@ -88,19 +119,41 @@ describe('HelpPopover runtime behavior', () => { hasIcon: true, }, { - label: 'X', + label: 'X (Twitter)', href: 'https://x.com/OpenKnowledgeAI', target: '_blank', rel: 'noopener noreferrer', hasIcon: true, }, { - label: 'OpenKnowledge', - href: 'https://openknowledge.ai/', + label: 'GitHub', + href: 'https://github.com/inkeep/open-knowledge', target: '_blank', rel: 'noopener noreferrer', hasIcon: true, }, ]); }); + + test('shows the fetched GitHub star count on the GitHub row', async () => { + await renderOpenHelpPopover(); + + const nav = screen.getByRole('navigation', { name: 'Community' }); + const githubLink = within(nav).getByRole('link', { name: /GitHub/ }); + await waitFor(() => expect(within(githubLink).getByText('1.2k')).not.toBeNull()); + }); + + test('Product updates exposes a What’s new link and a Subscribe action', async () => { + await renderOpenHelpPopover(); + + const nav = screen.getByRole('navigation', { name: 'Product updates' }); + const whatsNew = within(nav).getByRole('link', { name: "What's new" }); + expect(whatsNew.getAttribute('href')).toBe('https://github.com/inkeep/open-knowledge/releases'); + + const subscribe = within(nav).getByRole('button', { name: 'Subscribe' }); + expect(subscribe).not.toBeNull(); + + await userEvent.click(subscribe); + expect(screen.getByTestId('subscribe-email')).not.toBeNull(); + }); }); diff --git a/packages/app/src/components/HelpPopover.tsx b/packages/app/src/components/HelpPopover.tsx index 8a98b9e98..e2d703f18 100644 --- a/packages/app/src/components/HelpPopover.tsx +++ b/packages/app/src/components/HelpPopover.tsx @@ -1,9 +1,11 @@ +import { getGitHubStars } from '@inkeep/open-knowledge-core'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; -import { BookOpen, CircleHelp } from 'lucide-react'; -import type { ComponentProps, FC } from 'react'; -import { useState } from 'react'; +import { BookOpen, CircleDot, CircleHelp, Globe, Mail, Megaphone, Star } from 'lucide-react'; +import type { ComponentProps, FC, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { SubscribeForm } from '@/components/SubscribeForm'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -11,33 +13,117 @@ import { dispatchExternalLinkClick } from '@/lib/external-link'; import { cn } from '@/lib/utils'; import { DiscordIcon } from './icons/discord'; import { GithubIcon } from './icons/github'; -import { OkIcon } from './icons/ok'; import { XTwitterIcon } from './icons/x-twitter'; -const links: Array<{ +const GITHUB_REPO_URL = 'https://github.com/inkeep/open-knowledge'; + +interface ResourceLink { label: string | MessageDescriptor; href: string; icon: FC>; - iconClassName?: string; -}> = [ - { label: msg`Docs`, href: 'https://openknowledge.ai/docs', icon: BookOpen }, - { label: 'GitHub', href: 'https://github.com/inkeep/open-knowledge', icon: GithubIcon }, - { label: 'Discord', href: 'https://discord.com/invite/YujKpFN49', icon: DiscordIcon }, - { label: 'X', href: 'https://x.com/OpenKnowledgeAI', icon: XTwitterIcon }, +} + +interface ResourceSection { + key: string; + heading: MessageDescriptor; + links: ResourceLink[]; +} + +const sections: ResourceSection[] = [ { - label: 'OpenKnowledge', - href: 'https://openknowledge.ai/', - icon: OkIcon, - iconClassName: 'scale-125 grayscale transition-[filter] group-hover:grayscale-0', + key: 'resources', + heading: msg`Resources`, + links: [ + { label: msg`Docs`, href: 'https://openknowledge.ai/docs', icon: BookOpen }, + { label: msg`File an issue`, href: `${GITHUB_REPO_URL}/issues/new`, icon: CircleDot }, + { label: msg`Website`, href: 'https://openknowledge.ai/', icon: Globe }, + ], + }, + { + key: 'community', + heading: msg`Community`, + links: [ + { label: 'Discord', href: 'https://discord.com/invite/YujKpFN49', icon: DiscordIcon }, + { label: 'X (Twitter)', href: 'https://x.com/OpenKnowledgeAI', icon: XTwitterIcon }, + { label: 'GitHub', href: GITHUB_REPO_URL, icon: GithubIcon }, + ], }, ]; +const WHATS_NEW_HREF = `${GITHUB_REPO_URL}/releases`; + +const rowClassName = + 'group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-azure-900/5 dark:hover:bg-white/20 hover:text-primary'; + +function formatStarCount(count: number): string { + if (count < 1000) return String(count); + const thousands = count / 1000; + return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1)}k`; +} + +const StarCount: FC<{ count: number }> = ({ count }) => { + const { t } = useLingui(); + const formatted = formatStarCount(count); + return ( + + + ); +}; + +const ResourceLinkRow: FC<{ link: ResourceLink; trailing?: ReactNode }> = ({ link, trailing }) => { + const { t } = useLingui(); + const { label, href, icon: Icon } = link; + return ( +

  • + dispatchExternalLinkClick(e, href)} + onAuxClick={(e) => dispatchExternalLinkClick(e, href)} + className={rowClassName} + > + +
  • + ); +}; + +const SectionHeading: FC<{ children: ReactNode }> = ({ children }) => ( +

    {children}

    +); + export const HelpPopover: FC = () => { const { t } = useLingui(); const [popoverOpen, setPopoverOpen] = useState(false); + const [subscribeOpen, setSubscribeOpen] = useState(false); + const [starCount, setStarCount] = useState(null); + + useEffect(() => { + if (!popoverOpen || starCount !== null) return; + const controller = new AbortController(); + getGitHubStars({ signal: controller.signal }).then((count) => { + if (count !== null) setStarCount(count); + }); + return () => controller.abort(); + }, [popoverOpen, starCount]); return ( - + { + setPopoverOpen(open); + if (!open) setSubscribeOpen(false); + }} + > @@ -57,29 +143,59 @@ export const HelpPopover: FC = () => { Resources - -

    - Resources -

    - +
    ); diff --git a/packages/app/src/components/SubscribeForm.dom.test.tsx b/packages/app/src/components/SubscribeForm.dom.test.tsx new file mode 100644 index 000000000..3d1901ab3 --- /dev/null +++ b/packages/app/src/components/SubscribeForm.dom.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; + +mock.module('@lingui/react/macro', () => ({ + Trans: ({ children }: { children: ReactNode }) => <>{children}, + useLingui: () => ({ t: renderLinguiTemplate }), +})); + +const submitSubscribe = mock( + async (_email: string) => + ({ ok: true }) as Awaited>, +); +mock.module('@/lib/subscribe', () => ({ submitSubscribe })); + +async function renderForm(onSuccess?: () => void) { + const { SubscribeForm } = await import('./SubscribeForm'); + render(); +} + +describe('SubscribeForm', () => { + afterEach(() => { + cleanup(); + submitSubscribe.mockReset(); + submitSubscribe.mockResolvedValue({ ok: true }); + }); + + test('rejects an invalid email before hitting the network', async () => { + await renderForm(); + await userEvent.type(screen.getByTestId('subscribe-email'), 'not-an-email'); + await userEvent.click(screen.getByTestId('subscribe-submit')); + + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(submitSubscribe).not.toHaveBeenCalled(); + }); + + test('submits a valid email and shows the success view', async () => { + const onSuccess = mock(() => {}); + submitSubscribe.mockResolvedValue({ ok: true }); + await renderForm(onSuccess); + + await userEvent.type(screen.getByTestId('subscribe-email'), 'someone@example.com'); + await userEvent.click(screen.getByTestId('subscribe-submit')); + + await waitFor(() => expect(submitSubscribe).toHaveBeenCalledWith('someone@example.com')); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(await screen.findByText(/Watch your inbox/i)).not.toBeNull(); + expect(screen.queryByTestId('subscribe-email')).toBeNull(); + }); + + test('surfaces a server failure as a retryable alert, no success view', async () => { + submitSubscribe.mockResolvedValue({ ok: false, reason: 'error' }); + await renderForm(); + + await userEvent.type(screen.getByTestId('subscribe-email'), 'someone@example.com'); + await userEvent.click(screen.getByTestId('subscribe-submit')); + + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(screen.getByTestId('subscribe-email')).not.toBeNull(); + expect(screen.queryByText(/Watch your inbox/i)).toBeNull(); + }); + + test('maps reason "invalid" to the field-level email error, no success view', async () => { + submitSubscribe.mockResolvedValue({ ok: false, reason: 'invalid' }); + await renderForm(); + + await userEvent.type(screen.getByTestId('subscribe-email'), 'someone@example.com'); + await userEvent.click(screen.getByTestId('subscribe-submit')); + + expect(await screen.findByText(/Please enter a valid email address/i)).not.toBeNull(); + expect(screen.getByTestId('subscribe-email')).not.toBeNull(); + expect(screen.queryByText(/Watch your inbox/i)).toBeNull(); + }); + + test('maps reason "unavailable" to its distinct message, no success view', async () => { + submitSubscribe.mockResolvedValue({ ok: false, reason: 'unavailable' }); + await renderForm(); + + await userEvent.type(screen.getByTestId('subscribe-email'), 'someone@example.com'); + await userEvent.click(screen.getByTestId('subscribe-submit')); + + expect(await screen.findByText(/Subscriptions aren't available right now/i)).not.toBeNull(); + expect(screen.getByTestId('subscribe-email')).not.toBeNull(); + expect(screen.queryByText(/Watch your inbox/i)).toBeNull(); + }); +}); diff --git a/packages/app/src/components/SubscribeForm.tsx b/packages/app/src/components/SubscribeForm.tsx new file mode 100644 index 000000000..0104d3b60 --- /dev/null +++ b/packages/app/src/components/SubscribeForm.tsx @@ -0,0 +1,172 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { Check, Loader2, Mail, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'; +import { submitSubscribe } from '@/lib/subscribe'; +import { cn } from '@/lib/utils'; + +interface SubscribeValues { + email: string; +} + +export interface SubscribeFormProps { + title?: ReactNode; + description?: ReactNode; + onSuccess?: () => void; + onDismiss?: () => void; + autoFocus?: boolean; + className?: string; +} + +export function SubscribeForm({ + title, + description, + onSuccess, + onDismiss, + autoFocus, + className, +}: SubscribeFormProps) { + const { t } = useLingui(); + const [subscribed, setSubscribed] = useState(false); + const successHeadingRef = useRef(null); + + useEffect(() => { + if (subscribed) successHeadingRef.current?.focus(); + }, [subscribed]); + + const schema = z.object({ + email: z.email({ message: t`Please enter a valid email address.` }), + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { email: '' }, + }); + + const { isSubmitting } = form.formState; + const errorMessage = form.formState.errors.email?.message ?? form.formState.errors.root?.message; + + async function onSubmit(values: SubscribeValues) { + form.clearErrors('root'); + const result = await submitSubscribe(values.email); + if (result.ok) { + setSubscribed(true); + onSuccess?.(); + return; + } + if (result.reason === 'invalid') { + form.setError('email', { message: t`Please enter a valid email address.` }); + return; + } + if (result.reason === 'unavailable') { + form.setError('root', { message: t`Subscriptions aren't available right now.` }); + return; + } + form.setError('root', { message: t`Something went wrong. Please try again.` }); + } + + return ( +
    +
    +
    + {/* tabIndex/-1 + ref only matter post-success, when this heading + becomes the focus landing spot for the unmounted form. */} +

    + {subscribed ? ( + + + You're subscribed! + + ) : ( + (title ?? Stay in the loop) + )} +

    + {/* role="status" is unconditional so the live region is registered + before the success text replaces the description — adding the role + and the new content in the same render makes screen readers miss + the announcement (WCAG 4.1.3). */} +

    + {subscribed ? ( + Thanks for subscribing. Watch your inbox for product updates. + ) : ( + (description ?? Get product updates in your inbox.) + )} +

    +
    + {onDismiss ? ( + + ) : null} +
    + + {subscribed ? null : ( +
    + ( + + + + + + + + )} + /> + {errorMessage ? ( +

    + {errorMessage} +

    + ) : null} + + )} +
    + ); +} diff --git a/packages/app/src/lib/subscribe.ts b/packages/app/src/lib/subscribe.ts new file mode 100644 index 000000000..f11731aec --- /dev/null +++ b/packages/app/src/lib/subscribe.ts @@ -0,0 +1,34 @@ +const SUBSCRIBE_ENDPOINT = 'https://openknowledge.ai/api/subscribe'; + +export type SubscribeResult = + | { ok: true } + | { ok: false; reason: 'invalid' | 'unavailable' | 'error' }; + +export async function submitSubscribe(email: string): Promise { + try { + const response = await fetch(SUBSCRIBE_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + signal: AbortSignal.timeout(15_000), + }); + if (response.ok) { + return { ok: true }; + } + if (response.status === 400) { + return { ok: false, reason: 'invalid' }; + } + if (response.status === 503) { + return { ok: false, reason: 'unavailable' }; + } + console.warn(`[subscribe] action=submit result=http-error status=${response.status}`); + return { ok: false, reason: 'error' }; + } catch (err) { + console.warn( + `[subscribe] action=submit result=network-error message=${ + err instanceof Error ? err.message : String(err) + }`, + ); + return { ok: false, reason: 'error' }; + } +} diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 662902dec..2a6a6be2f 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -108,6 +108,7 @@ "2Znqb1": [" (missing)"], "2bVHbB": ["Available in every project on this computer."], "2fkdnR": ["Nothing to undo on this file"], + "2hYmUH": ["my@email.com"], "2hpdRq": ["Hide panel (", ["panelShortcutLabel"], ")"], "2n3oDU": [ "Project-level pages ordered by inbound link count, up to ", @@ -146,6 +147,7 @@ "3a_Bd7": ["Open source doc: ", ["mirrorSrc"]], "3aeN7X": ["All files"], "3cOo2C": ["Create a folder from the current document or folder context."], + "3ePd3I": ["What's new"], "3h5zMp": ["Something went wrong loading the settings panel."], "3iyGWF": ["Open recent project"], "3jod3l": ["Connect GitHub"], @@ -188,6 +190,7 @@ "4g19V6": ["Failed to update property"], "4hJhzz": ["Table"], "4jDd0j": ["Anyone can see"], + "4mLBx9": ["Subscriptions aren't available right now."], "4niEzY": ["Sandboxed HTML embed — write HTML, see the rendered preview live."], "4nj6n8": ["/api/config returned HTTP ", ["code"]], "4rjwLG": ["Create with ", ["0"], " CLI"], @@ -410,6 +413,7 @@ "AQGtjK": [ "Commands run with the full access of your macOS user account on this machine. Turn this off to disable the shell." ], + "ATGYL1": ["Email address"], "AVW-K9": ["Failed to load orphan pages"], "AWOSPo": ["Zoom in"], "AcK_r0": ["Claude CLI"], @@ -529,6 +533,7 @@ "E7BhZn": ["Blank note"], "E9bKSA": ["Creating"], "EBVbtP": ["Could not duplicate item"], + "EDl9kS": ["Subscribe"], "EDm3wJ": ["Loading diff renderer"], "EEYbdt": ["Publish"], "EJuO7B": ["Connect a GitHub account to browse and sync your repositories."], @@ -650,6 +655,7 @@ "GubkSu": ["Use a starter pack"], "GuqOb1": ["Failed to load headings"], "Gx0VS1": ["e.g. drafts/ or *.draft.md"], + "GzPQ2f": ["Stay in the loop"], "H-vDm3": ["Relaunching to install the update…"], "H0WlWQ": ["Grid of rows and columns with a header row."], "H86f9p": ["Collapse"], @@ -915,6 +921,7 @@ "Okd2Xv": ["Toggle underline formatting."], "OlAl5i": ["Expand all"], "OmHTGj": ["Disable git auto-sync"], + "On0aF2": ["Website"], "Oo_WWc": ["No pages found"], "Oos8fo": ["Copy command"], "OuY8t9": ["Open in current branch"], @@ -932,7 +939,9 @@ "PB4upl": ["Find previous match"], "PBwmDJ": ["Searching by meaning"], "PBxg_E": ["Not now"], + "PCNIuJ": ["File an issue"], "PCSkw2": ["Skills"], + "PIMM5l": ["You're subscribed!"], "PKLY-1": ["Opening..."], "PMyJ9C": ["No edit session yet"], "PPFQ3o": ["Installed \"", ["0"], "\" into ", ["1"]], @@ -1017,6 +1026,7 @@ "SFN6dN": ["Heading 3"], "STTOnz": [["keyName"], " type: complex value (nested; read-only)"], "SUOoXn": ["Failed to open project."], + "SVLPt5": [["formatted"], " GitHub stars"], "SXCSQd": ["Copied path"], "SYfHmN": ["Unpin tab"], "Sc1bKE": [ @@ -1142,6 +1152,8 @@ "Wgmlsw": ["Fetching"], "Wh0im4": ["editing [[", ["realCurrentDoc"], "]]"], "WhOsNE": ["Strikethrough"], + "WmPbvm": ["Get product updates in your inbox."], + "Wq6Wcm": ["Please enter a valid email address."], "WqLq6M": ["Open with AI ", ["displayName"]], "Wt5W56": ["Sync is off — your edits will not sync to the remote repository."], "Wtw22s": ["Installing"], @@ -1312,6 +1324,7 @@ "aiArms": ["We couldn't clone this repository."], "ajz9F8": ["Couldn't load conflict content for ", ["filePath"], ". Try reloading the page."], "aoa6xA": ["network error (is `ok ui` running?)"], + "arUUsK": ["Product updates"], "az8lvo": ["Off"], "b3Thhd": ["Upload failed"], "b4AQFK": ["Open activity panel for ", ["tooltipName"], ", editing ", ["realCurrentDoc"]], @@ -1357,6 +1370,7 @@ "cZ_8zB": ["Stays on this computer."], "cdm00A": ["No Incoming"], "cdmk8A": ["Uncommitted changes"], + "chL5IG": ["Community"], "cklVjM": ["Timeline"], "cn1sLi": ["Couldn't connect OpenKnowledge tools to Claude Code. Please try again."], "cnGeoo": ["Delete"], @@ -1435,6 +1449,7 @@ "fNr-MX": ["Disable semantic search"], "fVcm9t": [["0", "plural", { "one": ["#", " doc"], "other": ["#", " docs"] }]], "fWi7pn": ["Initialize starter pack"], + "fWsBTs": ["Something went wrong. Please try again."], "fYKgzG": ["Failed to load recent projects."], "fYjgXu": ["Unordered list of items."], "f_PkYv": [ @@ -1624,6 +1639,7 @@ "l6OXqI": ["No installed agents found"], "l8G3S9": ["Auto-sync is off — you don't have permission to push to this repo"], "lAiyU_": ["Only the current document uses <0>#", ["tag"], "."], + "lCC_D5": ["Subscribing..."], "lGs_bQ": ["Page <0>*"], "lHwYMr": ["Server error: ", ["status"], " ", ["statusText"]], "lI0Td2": ["Use letters, digits, <0>_ and <1>- only."], @@ -1776,6 +1792,7 @@ "p0Iq25": ["Insert link"], "p9sdCy": ["New ignore pattern"], "pAgkuZ": ["Reopening \"", ["docName"], "\" with a fresh local collaboration cache."], + "pBROkv": ["Thanks for subscribing. Watch your inbox for product updates."], "pEQFsW": ["No matching projects."], "pFagOn": ["Source editing"], "pHYP6v": ["Open <0>Customize in the sidebar <1/> <2>Skills."], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index ab4dec821..8e6c95af7 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -270,6 +270,10 @@ msgstr "{failedCount, plural, one {1 file failed to upload} other {{failedCount} msgid "{fileCount, plural, one {# file} other {# files}}" msgstr "{fileCount, plural, one {# file} other {# files}}" +#: src/components/HelpPopover.tsx +msgid "{formatted} GitHub stars" +msgstr "{formatted} GitHub stars" + #: src/components/ActivityModeContent.tsx #: src/components/ActivityPanelFileRow.tsx #: src/components/SyncStatusBadge.tsx @@ -1143,6 +1147,7 @@ msgstr "Cloning..." #: src/components/EditorTabs.tsx #: src/components/InstallInClaudeDesktopDialog.tsx #: src/components/SeedDialog.tsx +#: src/components/SubscribeForm.tsx #: src/components/ui/dialog.tsx #: src/components/ui/sheet.tsx #: src/editor/find-replace/FindReplaceBar.tsx @@ -1272,6 +1277,10 @@ msgstr "Commit or stash changes to switch:" msgid "Commits happen automatically" msgstr "Commits happen automatically" +#: src/components/HelpPopover.tsx +msgid "Community" +msgstr "Community" + #: src/components/empty-state/use-create-suggestions.ts msgid "Competitor research" msgstr "Competitor research" @@ -2330,6 +2339,10 @@ msgstr "Edits at the source land here — no copy-paste drift." msgid "Email" msgstr "Email" +#: src/components/SubscribeForm.tsx +msgid "Email address" +msgstr "Email address" + #: src/editor/slash-command/component-items.tsx msgid "Embed a video with native player controls." msgstr "Embed a video with native player controls." @@ -2715,6 +2728,10 @@ msgstr "File" msgid "File already exists" msgstr "File already exists" +#: src/components/HelpPopover.tsx +msgid "File an issue" +msgstr "File an issue" + #: src/components/FileTree.tsx msgid "File drop zone" msgstr "File drop zone" @@ -2899,6 +2916,10 @@ msgstr "Get {displayName}" msgid "Get Claude Code" msgstr "Get Claude Code" +#: src/components/SubscribeForm.tsx +msgid "Get product updates in your inbox." +msgstr "Get product updates in your inbox." + #: src/components/settings/SettingsDialogBody.tsx msgid "Git auto-sync" msgstr "Git auto-sync" @@ -3748,6 +3769,10 @@ msgstr "moved template" msgid "Multi-page PDF viewer with toolbar controls (thumbnails, page nav, zoom)." msgstr "Multi-page PDF viewer with toolbar controls (thumbnails, page nav, zoom)." +#: src/components/SubscribeForm.tsx +msgid "my@email.com" +msgstr "my@email.com" + #: src/components/SkillProperties.tsx #: src/components/TemplateProperties.tsx msgid "name" @@ -4692,6 +4717,10 @@ msgstr "Pin tab" msgid "Plain" msgstr "Plain" +#: src/components/SubscribeForm.tsx +msgid "Please enter a valid email address." +msgstr "Please enter a valid email address." + #: src/components/settings/SettingsDialogBody.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "Preferences" @@ -4729,6 +4758,10 @@ msgstr "Private repository" msgid "probe failed" msgstr "probe failed" +#: src/components/HelpPopover.tsx +msgid "Product updates" +msgstr "Product updates" + #: src/components/settings/ProjectTemplatesSection.tsx msgid "project" msgstr "project" @@ -5777,6 +5810,10 @@ msgstr "Something went wrong opening this file (HTTP {status})." msgid "Something went wrong starting the terminal." msgstr "Something went wrong starting the terminal." +#: src/components/SubscribeForm.tsx +msgid "Something went wrong. Please try again." +msgstr "Something went wrong. Please try again." + #: src/lib/keyboard-shortcuts.ts msgid "Source editing" msgstr "Source editing" @@ -5861,6 +5898,10 @@ msgstr "Starting sign-in flow" msgid "status" msgstr "status" +#: src/components/SubscribeForm.tsx +msgid "Stay in the loop" +msgstr "Stay in the loop" + #: src/components/settings/SharingSection.tsx #: src/components/SharingModeField.tsx msgid "Stays on this computer." @@ -5886,6 +5927,19 @@ msgstr "Strikethrough" msgid "Subfolder under current folder" msgstr "Subfolder under current folder" +#: src/components/HelpPopover.tsx +#: src/components/SubscribeForm.tsx +msgid "Subscribe" +msgstr "Subscribe" + +#: src/components/SubscribeForm.tsx +msgid "Subscribing..." +msgstr "Subscribing..." + +#: src/components/SubscribeForm.tsx +msgid "Subscriptions aren't available right now." +msgstr "Subscriptions aren't available right now." + #: src/lib/keyboard-shortcuts.ts msgid "Suggestion menu navigation" msgstr "Suggestion menu navigation" @@ -6114,6 +6168,10 @@ msgstr "Terminal settings not loaded yet — try again in a moment." msgid "Text" msgstr "Text" +#: src/components/SubscribeForm.tsx +msgid "Thanks for subscribing. Watch your inbox for product updates." +msgstr "Thanks for subscribing. Watch your inbox for product updates." + #: src/components/ShareReceiveDialog.tsx msgid "That folder isn't an OpenKnowledge project yet. Initialize it and open?" msgstr "That folder isn't an OpenKnowledge project yet. Initialize it and open?" @@ -6808,6 +6866,10 @@ msgstr "We'll build <0>openknowledge.skill, save it to <1>~/Downloads, a msgid "We'll create a repository and start syncing — no terminal needed." msgstr "We'll create a repository and start syncing — no terminal needed." +#: src/components/HelpPopover.tsx +msgid "Website" +msgstr "Website" + #: src/components/NewSkillDialog.tsx msgid "What agents match on to decide when to use the skill." msgstr "What agents match on to decide when to use the skill." @@ -6833,6 +6895,10 @@ msgstr "What should the AI do? (optional)" msgid "What would you like to create?" msgstr "What would you like to create?" +#: src/components/HelpPopover.tsx +msgid "What's new" +msgstr "What's new" + #: src/components/settings/SettingsDialogBody.tsx msgid "When enabled, the agent opens or refreshes the preview after each edit. Disable if you manage your own preview window (OK Desktop, a browser tab on another display, etc.)." msgstr "When enabled, the agent opens or refreshes the preview after each edit. Disable if you manage your own preview window (OK Desktop, a browser tab on another display, etc.)." @@ -6962,6 +7028,10 @@ msgstr "You picked the filesystem root (/). Scaffolding here will scan every fil msgid "You picked your home directory. OpenKnowledge will index everything in your home tree — large and may surface personal files." msgstr "You picked your home directory. OpenKnowledge will index everything in your home tree — large and may surface personal files." +#: src/components/SubscribeForm.tsx +msgid "You're subscribed!" +msgstr "You're subscribed!" + #: src/components/settings/SettingsDialogBody.tsx #: src/components/SyncStatusBadge.tsx msgid "Your GitHub session expired — sign in again to verify push access." diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 99aeea713..e56121e63 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -108,6 +108,7 @@ "2Znqb1": [" (ḿĩśśĩńĝ)"], "2bVHbB": ["Àvàĩĺàƀĺē ĩń ēvēŕŷ ƥŕōĴēćţ ōń ţĥĩś ćōḿƥũţēŕ."], "2fkdnR": ["Ńōţĥĩńĝ ţō ũńďō ōń ţĥĩś ƒĩĺē"], + "2hYmUH": ["ḿŷ@ēḿàĩĺ.ćōḿ"], "2hpdRq": ["Ĥĩďē ƥàńēĺ (", ["panelShortcutLabel"], ")"], "2n3oDU": [ "ƤŕōĴēćţ-ĺēvēĺ ƥàĝēś ōŕďēŕēď ƀŷ ĩńƀōũńď ĺĩńķ ćōũńţ, ũƥ ţō ", @@ -146,6 +147,7 @@ "3a_Bd7": ["Ōƥēń śōũŕćē ďōć: ", ["mirrorSrc"]], "3aeN7X": ["Àĺĺ ƒĩĺēś"], "3cOo2C": ["Ćŕēàţē à ƒōĺďēŕ ƒŕōḿ ţĥē ćũŕŕēńţ ďōćũḿēńţ ōŕ ƒōĺďēŕ ćōńţēxţ."], + "3ePd3I": ["Ŵĥàţ'ś ńēŵ"], "3h5zMp": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ ĺōàďĩńĝ ţĥē śēţţĩńĝś ƥàńēĺ."], "3iyGWF": ["Ōƥēń ŕēćēńţ ƥŕōĴēćţ"], "3jod3l": ["Ćōńńēćţ ĜĩţĤũƀ"], @@ -188,6 +190,7 @@ "4g19V6": ["Ƒàĩĺēď ţō ũƥďàţē ƥŕōƥēŕţŷ"], "4hJhzz": ["Ţàƀĺē"], "4jDd0j": ["Àńŷōńē ćàń śēē"], + "4mLBx9": ["Śũƀśćŕĩƥţĩōńś àŕēń'ţ àvàĩĺàƀĺē ŕĩĝĥţ ńōŵ."], "4niEzY": ["Śàńďƀōxēď ĤŢḾĹ ēḿƀēď — ŵŕĩţē ĤŢḾĹ, śēē ţĥē ŕēńďēŕēď ƥŕēvĩēŵ ĺĩvē."], "4nj6n8": ["/àƥĩ/ćōńƒĩĝ ŕēţũŕńēď ĤŢŢƤ ", ["code"]], "4rjwLG": ["Ćŕēàţē ŵĩţĥ ", ["0"], " ĆĹĨ"], @@ -410,6 +413,7 @@ "AQGtjK": [ "Ćōḿḿàńďś ŕũń ŵĩţĥ ţĥē ƒũĺĺ àććēśś ōƒ ŷōũŕ ḿàćŌŚ ũśēŕ àććōũńţ ōń ţĥĩś ḿàćĥĩńē. Ţũŕń ţĥĩś ōƒƒ ţō ďĩśàƀĺē ţĥē śĥēĺĺ." ], + "ATGYL1": ["Ēḿàĩĺ àďďŕēśś"], "AVW-K9": ["Ƒàĩĺēď ţō ĺōàď ōŕƥĥàń ƥàĝēś"], "AWOSPo": ["Źōōḿ ĩń"], "AcK_r0": ["Ćĺàũďē ĆĹĨ"], @@ -529,6 +533,7 @@ "E7BhZn": ["ßĺàńķ ńōţē"], "E9bKSA": ["Ćŕēàţĩńĝ"], "EBVbtP": ["Ćōũĺď ńōţ ďũƥĺĩćàţē ĩţēḿ"], + "EDl9kS": ["Śũƀśćŕĩƀē"], "EDm3wJ": ["Ĺōàďĩńĝ ďĩƒƒ ŕēńďēŕēŕ"], "EEYbdt": ["Ƥũƀĺĩśĥ"], "EJuO7B": ["Ćōńńēćţ à ĜĩţĤũƀ àććōũńţ ţō ƀŕōŵśē àńď śŷńć ŷōũŕ ŕēƥōśĩţōŕĩēś."], @@ -650,6 +655,7 @@ "GubkSu": ["Ũśē à śţàŕţēŕ ƥàćķ"], "GuqOb1": ["Ƒàĩĺēď ţō ĺōàď ĥēàďĩńĝś"], "Gx0VS1": ["ē.ĝ. ďŕàƒţś/ ōŕ *.ďŕàƒţ.ḿď"], + "GzPQ2f": ["Śţàŷ ĩń ţĥē ĺōōƥ"], "H-vDm3": ["Ŕēĺàũńćĥĩńĝ ţō ĩńśţàĺĺ ţĥē ũƥďàţē…"], "H0WlWQ": ["Ĝŕĩď ōƒ ŕōŵś àńď ćōĺũḿńś ŵĩţĥ à ĥēàďēŕ ŕōŵ."], "H86f9p": ["Ćōĺĺàƥśē"], @@ -915,6 +921,7 @@ "Okd2Xv": ["Ţōĝĝĺē ũńďēŕĺĩńē ƒōŕḿàţţĩńĝ."], "OlAl5i": ["Ēxƥàńď àĺĺ"], "OmHTGj": ["Ďĩśàƀĺē ĝĩţ àũţō-śŷńć"], + "On0aF2": ["Ŵēƀśĩţē"], "Oo_WWc": ["Ńō ƥàĝēś ƒōũńď"], "Oos8fo": ["Ćōƥŷ ćōḿḿàńď"], "OuY8t9": ["Ōƥēń ĩń ćũŕŕēńţ ƀŕàńćĥ"], @@ -932,7 +939,9 @@ "PB4upl": ["Ƒĩńď ƥŕēvĩōũś ḿàţćĥ"], "PBwmDJ": ["Śēàŕćĥĩńĝ ƀŷ ḿēàńĩńĝ"], "PBxg_E": ["Ńōţ ńōŵ"], + "PCNIuJ": ["Ƒĩĺē àń ĩśśũē"], "PCSkw2": ["Śķĩĺĺś"], + "PIMM5l": ["Ŷōũ'ŕē śũƀśćŕĩƀēď!"], "PKLY-1": ["Ōƥēńĩńĝ..."], "PMyJ9C": ["Ńō ēďĩţ śēśśĩōń ŷēţ"], "PPFQ3o": ["Ĩńśţàĺĺēď \"", ["0"], "\" ĩńţō ", ["1"]], @@ -1017,6 +1026,7 @@ "SFN6dN": ["Ĥēàďĩńĝ 3"], "STTOnz": [["keyName"], " ţŷƥē: ćōḿƥĺēx vàĺũē (ńēśţēď; ŕēàď-ōńĺŷ)"], "SUOoXn": ["Ƒàĩĺēď ţō ōƥēń ƥŕōĴēćţ."], + "SVLPt5": [["formatted"], " ĜĩţĤũƀ śţàŕś"], "SXCSQd": ["Ćōƥĩēď ƥàţĥ"], "SYfHmN": ["Ũńƥĩń ţàƀ"], "Sc1bKE": [ @@ -1142,6 +1152,8 @@ "Wgmlsw": ["Ƒēţćĥĩńĝ"], "Wh0im4": ["ēďĩţĩńĝ [[", ["realCurrentDoc"], "]]"], "WhOsNE": ["Śţŕĩķēţĥŕōũĝĥ"], + "WmPbvm": ["Ĝēţ ƥŕōďũćţ ũƥďàţēś ĩń ŷōũŕ ĩńƀōx."], + "Wq6Wcm": ["Ƥĺēàśē ēńţēŕ à vàĺĩď ēḿàĩĺ àďďŕēśś."], "WqLq6M": ["Ōƥēń ŵĩţĥ ÀĨ ", ["displayName"]], "Wt5W56": ["Śŷńć ĩś ōƒƒ — ŷōũŕ ēďĩţś ŵĩĺĺ ńōţ śŷńć ţō ţĥē ŕēḿōţē ŕēƥōśĩţōŕŷ."], "Wtw22s": ["Ĩńśţàĺĺĩńĝ"], @@ -1312,6 +1324,7 @@ "aiArms": ["Ŵē ćōũĺďń'ţ ćĺōńē ţĥĩś ŕēƥōśĩţōŕŷ."], "ajz9F8": ["Ćōũĺďń'ţ ĺōàď ćōńƒĺĩćţ ćōńţēńţ ƒōŕ ", ["filePath"], ". Ţŕŷ ŕēĺōàďĩńĝ ţĥē ƥàĝē."], "aoa6xA": ["ńēţŵōŕķ ēŕŕōŕ (ĩś `ōķ ũĩ` ŕũńńĩńĝ?)"], + "arUUsK": ["Ƥŕōďũćţ ũƥďàţēś"], "az8lvo": ["Ōƒƒ"], "b3Thhd": ["Ũƥĺōàď ƒàĩĺēď"], "b4AQFK": ["Ōƥēń àćţĩvĩţŷ ƥàńēĺ ƒōŕ ", ["tooltipName"], ", ēďĩţĩńĝ ", ["realCurrentDoc"]], @@ -1357,6 +1370,7 @@ "cZ_8zB": ["Śţàŷś ōń ţĥĩś ćōḿƥũţēŕ."], "cdm00A": ["Ńō Ĩńćōḿĩńĝ"], "cdmk8A": ["Ũńćōḿḿĩţţēď ćĥàńĝēś"], + "chL5IG": ["Ćōḿḿũńĩţŷ"], "cklVjM": ["Ţĩḿēĺĩńē"], "cn1sLi": ["Ćōũĺďń'ţ ćōńńēćţ ŌƥēńĶńōŵĺēďĝē ţōōĺś ţō Ćĺàũďē Ćōďē. Ƥĺēàśē ţŕŷ àĝàĩń."], "cnGeoo": ["Ďēĺēţē"], @@ -1435,6 +1449,7 @@ "fNr-MX": ["Ďĩśàƀĺē śēḿàńţĩć śēàŕćĥ"], "fVcm9t": [["0", "plural", { "one": ["#", " ďōć"], "other": ["#", " ďōćś"] }]], "fWi7pn": ["Ĩńĩţĩàĺĩźē śţàŕţēŕ ƥàćķ"], + "fWsBTs": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ. Ƥĺēàśē ţŕŷ àĝàĩń."], "fYKgzG": ["Ƒàĩĺēď ţō ĺōàď ŕēćēńţ ƥŕōĴēćţś."], "fYjgXu": ["Ũńōŕďēŕēď ĺĩśţ ōƒ ĩţēḿś."], "f_PkYv": [ @@ -1624,6 +1639,7 @@ "l6OXqI": ["Ńō ĩńśţàĺĺēď àĝēńţś ƒōũńď"], "l8G3S9": ["Àũţō-śŷńć ĩś ōƒƒ — ŷōũ ďōń'ţ ĥàvē ƥēŕḿĩśśĩōń ţō ƥũśĥ ţō ţĥĩś ŕēƥō"], "lAiyU_": ["Ōńĺŷ ţĥē ćũŕŕēńţ ďōćũḿēńţ ũśēś <0>#", ["tag"], "."], + "lCC_D5": ["Śũƀśćŕĩƀĩńĝ..."], "lGs_bQ": ["Ƥàĝē <0>*"], "lHwYMr": ["Śēŕvēŕ ēŕŕōŕ: ", ["status"], " ", ["statusText"]], "lI0Td2": ["Ũśē ĺēţţēŕś, ďĩĝĩţś, <0>_ àńď <1>- ōńĺŷ."], @@ -1776,6 +1792,7 @@ "p0Iq25": ["Ĩńśēŕţ ĺĩńķ"], "p9sdCy": ["Ńēŵ ĩĝńōŕē ƥàţţēŕń"], "pAgkuZ": ["Ŕēōƥēńĩńĝ \"", ["docName"], "\" ŵĩţĥ à ƒŕēśĥ ĺōćàĺ ćōĺĺàƀōŕàţĩōń ćàćĥē."], + "pBROkv": ["Ţĥàńķś ƒōŕ śũƀśćŕĩƀĩńĝ. Ŵàţćĥ ŷōũŕ ĩńƀōx ƒōŕ ƥŕōďũćţ ũƥďàţēś."], "pEQFsW": ["Ńō ḿàţćĥĩńĝ ƥŕōĴēćţś."], "pFagOn": ["Śōũŕćē ēďĩţĩńĝ"], "pHYP6v": ["Ōƥēń <0>Ćũśţōḿĩźē ĩń ţĥē śĩďēƀàŕ <1/> <2>Śķĩĺĺś."], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 3e1e93599..1c57c1629 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -270,6 +270,10 @@ msgstr "" msgid "{fileCount, plural, one {# file} other {# files}}" msgstr "" +#: src/components/HelpPopover.tsx +msgid "{formatted} GitHub stars" +msgstr "" + #: src/components/ActivityModeContent.tsx #: src/components/ActivityPanelFileRow.tsx #: src/components/SyncStatusBadge.tsx @@ -1138,6 +1142,7 @@ msgstr "" #: src/components/EditorTabs.tsx #: src/components/InstallInClaudeDesktopDialog.tsx #: src/components/SeedDialog.tsx +#: src/components/SubscribeForm.tsx #: src/components/ui/dialog.tsx #: src/components/ui/sheet.tsx #: src/editor/find-replace/FindReplaceBar.tsx @@ -1267,6 +1272,10 @@ msgstr "" msgid "Commits happen automatically" msgstr "" +#: src/components/HelpPopover.tsx +msgid "Community" +msgstr "" + #: src/components/empty-state/use-create-suggestions.ts msgid "Competitor research" msgstr "" @@ -2325,6 +2334,10 @@ msgstr "" msgid "Email" msgstr "" +#: src/components/SubscribeForm.tsx +msgid "Email address" +msgstr "" + #: src/editor/slash-command/component-items.tsx msgid "Embed a video with native player controls." msgstr "" @@ -2710,6 +2723,10 @@ msgstr "" msgid "File already exists" msgstr "" +#: src/components/HelpPopover.tsx +msgid "File an issue" +msgstr "" + #: src/components/FileTree.tsx msgid "File drop zone" msgstr "" @@ -2894,6 +2911,10 @@ msgstr "" msgid "Get Claude Code" msgstr "" +#: src/components/SubscribeForm.tsx +msgid "Get product updates in your inbox." +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "Git auto-sync" msgstr "" @@ -3743,6 +3764,10 @@ msgstr "" msgid "Multi-page PDF viewer with toolbar controls (thumbnails, page nav, zoom)." msgstr "" +#: src/components/SubscribeForm.tsx +msgid "my@email.com" +msgstr "" + #: src/components/SkillProperties.tsx #: src/components/TemplateProperties.tsx msgid "name" @@ -4687,6 +4712,10 @@ msgstr "" msgid "Plain" msgstr "" +#: src/components/SubscribeForm.tsx +msgid "Please enter a valid email address." +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "Preferences" @@ -4724,6 +4753,10 @@ msgstr "" msgid "probe failed" msgstr "" +#: src/components/HelpPopover.tsx +msgid "Product updates" +msgstr "" + #: src/components/settings/ProjectTemplatesSection.tsx msgid "project" msgstr "" @@ -5772,6 +5805,10 @@ msgstr "" msgid "Something went wrong starting the terminal." msgstr "" +#: src/components/SubscribeForm.tsx +msgid "Something went wrong. Please try again." +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Source editing" msgstr "" @@ -5856,6 +5893,10 @@ msgstr "" msgid "status" msgstr "" +#: src/components/SubscribeForm.tsx +msgid "Stay in the loop" +msgstr "" + #: src/components/settings/SharingSection.tsx #: src/components/SharingModeField.tsx msgid "Stays on this computer." @@ -5881,6 +5922,19 @@ msgstr "" msgid "Subfolder under current folder" msgstr "" +#: src/components/HelpPopover.tsx +#: src/components/SubscribeForm.tsx +msgid "Subscribe" +msgstr "" + +#: src/components/SubscribeForm.tsx +msgid "Subscribing..." +msgstr "" + +#: src/components/SubscribeForm.tsx +msgid "Subscriptions aren't available right now." +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Suggestion menu navigation" msgstr "" @@ -6109,6 +6163,10 @@ msgstr "" msgid "Text" msgstr "" +#: src/components/SubscribeForm.tsx +msgid "Thanks for subscribing. Watch your inbox for product updates." +msgstr "" + #: src/components/ShareReceiveDialog.tsx msgid "That folder isn't an OpenKnowledge project yet. Initialize it and open?" msgstr "" @@ -6803,6 +6861,10 @@ msgstr "" msgid "We'll create a repository and start syncing — no terminal needed." msgstr "" +#: src/components/HelpPopover.tsx +msgid "Website" +msgstr "" + #: src/components/NewSkillDialog.tsx msgid "What agents match on to decide when to use the skill." msgstr "" @@ -6828,6 +6890,10 @@ msgstr "" msgid "What would you like to create?" msgstr "" +#: src/components/HelpPopover.tsx +msgid "What's new" +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "When enabled, the agent opens or refreshes the preview after each edit. Disable if you manage your own preview window (OK Desktop, a browser tab on another display, etc.)." msgstr "" @@ -6957,6 +7023,10 @@ msgstr "" msgid "You picked your home directory. OpenKnowledge will index everything in your home tree — large and may surface personal files." msgstr "" +#: src/components/SubscribeForm.tsx +msgid "You're subscribed!" +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx #: src/components/SyncStatusBadge.tsx msgid "Your GitHub session expired — sign in again to verify push access." diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6e8611aee..7eb3a7c3a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1071,6 +1071,7 @@ export { createCodeFenceTracker } from './utils/code-fence-tracker.ts'; export { isEmbedUrlRewritable, rewriteEmbedUrl } from './utils/embed-url-rewrite.ts'; export { extensionOf } from './utils/extension.ts'; export { formatFileSize } from './utils/file-size.ts'; +export { getGitHubStars } from './utils/github-stars.ts'; export { AGENT_COLORS, AGENT_ICON_COLORS, diff --git a/docs/src/lib/github-stars.ts b/packages/core/src/utils/github-stars.ts similarity index 64% rename from docs/src/lib/github-stars.ts rename to packages/core/src/utils/github-stars.ts index b63da28cd..eb408e11d 100644 --- a/docs/src/lib/github-stars.ts +++ b/packages/core/src/utils/github-stars.ts @@ -1,14 +1,18 @@ const REPO_API_URL = 'https://api.github.com/repos/inkeep/open-knowledge'; -export async function getGitHubStars(): Promise { +export async function getGitHubStars(init?: RequestInit): Promise { + const { signal: callerSignal, ...restInit } = init ?? {}; + const signal = callerSignal + ? AbortSignal.any([AbortSignal.timeout(5_000), callerSignal]) + : AbortSignal.timeout(5_000); try { const res = await fetch(REPO_API_URL, { - signal: AbortSignal.timeout(5_000), - next: { revalidate: 3600 }, headers: { accept: 'application/vnd.github+json', - 'user-agent': 'openknowledge.ai site nav', + 'user-agent': 'openknowledge.ai', }, + ...restInit, + signal, }); if (!res.ok) { console.warn(`[github-stars] GitHub API responded ${res.status}`);