diff --git a/site/app/components/AccountMenu.tsx b/site/app/components/AccountMenu.tsx index 7ab062e..b9e929a 100644 --- a/site/app/components/AccountMenu.tsx +++ b/site/app/components/AccountMenu.tsx @@ -111,6 +111,7 @@ export default function AccountMenu() { { to: '/admin', label: 'admin' }, { to: `${pathname}?edit`, label: 'edit this page' }, { to: '/messages', label: 'moderate messages' }, + { to: '/admin#subscribers', label: 'subscribers' }, ...(import.meta.env.DEV ? [{ to: '/kit', label: 'kit' }] : []), ] : []; diff --git a/site/app/components/Shell.tsx b/site/app/components/Shell.tsx index 485fff1..eeb40ce 100644 --- a/site/app/components/Shell.tsx +++ b/site/app/components/Shell.tsx @@ -7,6 +7,7 @@ import { defaults, visible, type NAV } from '../settings'; import AccountMenu from './AccountMenu'; import { Editable, useEditing } from './kit/Editable'; import { openSearch, Palette } from './kit/Palette'; +import { SubscribeForm, SubscribeLink } from './kit/subscribe'; const NAV_TARGET: Record<(typeof NAV)[number], { to: string; label: string }> = { members: { to: '/members', label: 'members' }, @@ -81,11 +82,18 @@ export default function Shell({ children }: { children: ReactNode }) { diff --git a/site/app/components/kit/subscribe.tsx b/site/app/components/kit/subscribe.tsx new file mode 100644 index 0000000..7a659b9 --- /dev/null +++ b/site/app/components/kit/subscribe.tsx @@ -0,0 +1,219 @@ +import { useId, useState } from 'react'; +import { Link, useFetcher } from 'react-router'; +import { useHub } from '../../hub'; +import { useMagnetic } from '../../motion/motion'; +import { describeChoice, scopeKey, type Cadence, type Scope } from '../../subscribe'; +import { accentStyle, Avatar, Panel } from './basics'; +import { Chips } from './directory'; + +type Outcome = { sent: true } | { reason: string }; + +export function SubscribeForm({ + scope, + cadence, + compact = false, + action = '/subscribe', +}: { + scope: Scope; + cadence: Cadence; + compact?: boolean; + action?: string; +}) { + const fetcher = useFetcher(); + const magnetic = useMagnetic(); + const id = useId(); + const sent = fetcher.data && 'sent' in fetcher.data; + const reason = fetcher.data && 'reason' in fetcher.data ? fetcher.data.reason : null; + return ( + + + + + + {sent ? ( +

+ + check your inbox to confirm +

+ ) : ( + <> + + + + + )} + {reason &&

{reason}

} +
+ ); +} + +export function SubscribePanel({ scope, title = 'by email' }: { scope: Scope; title?: string }) { + const hub = useHub(); + return ( + +

{describeChoice(scope, 'each', hub)}, in your inbox.

+ +
+ ); +} + +const KINDS = [ + ['all', 'everything'], + ['project', 'a project'], + ['member', 'a member'], +] as const; + +export function Chooser({ + scope, + cadence, + onScope, + onCadence, +}: { + scope: Scope; + cadence: Cadence; + onScope: (next: Scope) => void; + onCadence: (next: Cadence) => void; +}) { + const hub = useHub(); + const [lastProject, setLastProject] = useState( + scope.kind === 'project' ? scope.slug : hub.projects[0]?.slug, + ); + const [lastMember, setLastMember] = useState( + scope.kind === 'member' ? scope.handle : hub.members[0]?.handle, + ); + const pickKind = (kind: Scope['kind']) => { + if (kind === 'all') { + onScope({ kind }); + } else if (kind === 'project' && lastProject) { + onScope({ kind, slug: lastProject }); + } else if (kind === 'member' && lastMember) { + onScope({ kind, handle: lastMember }); + } + }; + const sentence = describeChoice(scope, cadence, hub); + + return ( +
+
+ {KINDS.map(([kind, label]) => ( + + ))} +
+ {scope.kind === 'project' && ( + { + setLastProject(slug); + onScope({ kind: 'project', slug }); + }} + choices={hub.projects.map((project) => ({ + id: project.slug, + label: project.name, + count: hub.postsAbout(project).length || undefined, + }))} + /> + )} + {scope.kind === 'member' && ( +
+ {hub.members.map((member) => ( + + ))} +
+ )} +
+ + + + +

+ You will get {sentence}. +

+
+
+ ); +} + +export function SubscribeLink() { + return ( + + choose what you get + + ); +} diff --git a/site/app/context.ts b/site/app/context.ts index b20414e..7b60476 100644 --- a/site/app/context.ts +++ b/site/app/context.ts @@ -11,6 +11,7 @@ import type { Writeback } from '../server/writeback.ts'; import type { Uploads } from '../server/uploads.ts'; import type { Share } from '../server/share.ts'; import type { Reads } from '../server/reads.ts'; +import type { Subscribers } from '../server/subscribers.ts'; import type { Values } from './settings'; import type { Directory, Viewer } from './types'; @@ -26,6 +27,7 @@ export interface Hub { writeback: Writeback; uploads: Uploads; posts: Posts; + subscribers: Subscribers; collab: Collab; address: string; origin: string; diff --git a/site/app/meta.ts b/site/app/meta.ts index aa56490..b00ee78 100644 --- a/site/app/meta.ts +++ b/site/app/meta.ts @@ -12,6 +12,7 @@ export interface PageMeta { title: string; description: string; canonical: string; + noindex?: boolean; } export const INDEX: PageMeta = { @@ -77,6 +78,11 @@ export function metaFor(model: Model | undefined, rawPath: string): PageMeta { description: 'Search members, projects and releases.', canonical: `${ORIGIN}/search/`, }, + '/subscribe': { + title: `Subscribe — ${SITE}`, + description: 'New writing by email: every post as it publishes, or a weekly digest.', + canonical: `${ORIGIN}/subscribe/`, + }, '/privacy': { title: `Privacy — ${SITE}`, description: 'What Project Graphite keeps, and for how long.', @@ -111,6 +117,14 @@ export function metaFor(model: Model | undefined, rawPath: string): PageMeta { if (fixed[path]) { return fixed[path]; } + if (path.startsWith('/subscribe/')) { + return { + title: `Your subscription — ${SITE}`, + description: 'What you get by email, and how to stop.', + canonical: `${ORIGIN}/subscribe/`, + noindex: true, + }; + } if (!model) { return NOT_FOUND(path); } @@ -204,12 +218,13 @@ export function metaFor(model: Model | undefined, rawPath: string): PageMeta { } export function metaTags( - { title, description, canonical }: PageMeta, + { title, description, canonical, noindex }: PageMeta, image: string | undefined, ): MetaDescriptor[] { return [ { title }, { name: 'description', content: description }, + ...(noindex ? [{ name: 'robots', content: 'noindex' }] : []), { tagName: 'link', rel: 'canonical', href: canonical }, { property: 'og:title', content: title }, { property: 'og:description', content: description }, diff --git a/site/app/pages/member-page.tsx b/site/app/pages/member-page.tsx index dbfd70b..a4973b3 100644 --- a/site/app/pages/member-page.tsx +++ b/site/app/pages/member-page.tsx @@ -4,6 +4,7 @@ import { accentStyle, Avatar, Button, Panel } from '../components/kit/basics'; import { blockTitle, Blocks, useRenderedBlocks, type PageProfile } from '../components/kit/blocks'; import { Fact, PageHeader } from '../components/kit/page'; import { ChangelogPanel, RunningPanel } from '../components/kit/panels'; +import { SubscribePanel } from '../components/kit/subscribe'; import { Page } from '../components/Shell'; import type { Hub } from '../context'; import { useHub, useRoot } from '../hub'; @@ -116,6 +117,10 @@ export function MemberPage({ data }: { data: ReturnType }) { /> )} {data.running.length > 0 && } + } > diff --git a/site/app/pages/project-page.tsx b/site/app/pages/project-page.tsx index 8b3cf5b..6a666e0 100644 --- a/site/app/pages/project-page.tsx +++ b/site/app/pages/project-page.tsx @@ -6,6 +6,7 @@ import { ChangelogPanel, RunningPanel } from '../components/kit/panels'; import { PostCard, WritingStrip } from '../components/kit/posts'; import { WindowBar } from '../components/kit/projects'; import { ReleaseHistory } from '../components/kit/releases'; +import { SubscribePanel } from '../components/kit/subscribe'; import ProjectTile from '../components/ProjectTile'; import Section from '../components/Section'; import { Page } from '../components/Shell'; @@ -70,6 +71,10 @@ export function ProjectPage({ data: loaderData }: { data: ReturnType projectBySlug(release.project)} /> + {authors.length > 0 && (
diff --git a/site/app/routes.ts b/site/app/routes.ts index 8fbee71..49b1ddf 100644 --- a/site/app/routes.ts +++ b/site/app/routes.ts @@ -14,6 +14,8 @@ export default [ route('admin', 'routes/admin.tsx'), route('search', 'routes/search.tsx'), route('messages', 'routes/messages.tsx'), + route('subscribe', 'routes/subscribe.tsx'), + route('subscribe/:purpose/:id/:sig', 'routes/subscription.tsx'), route('privacy', 'routes/privacy.tsx'), route('sign-in', 'routes/sign-in.tsx'), route('changelog/members/:handle', 'routes/member-changelog.tsx'), diff --git a/site/app/routes/admin.tsx b/site/app/routes/admin.tsx index 0cb2b98..88fe472 100644 --- a/site/app/routes/admin.tsx +++ b/site/app/routes/admin.tsx @@ -12,6 +12,7 @@ import { isSettingKey, SETTINGS, type Area, + type ChoiceSetting, type ColorsSetting, type NumberSetting, type OrderItem, @@ -20,8 +21,9 @@ import { type SettingKey, type TextSetting, } from '../settings'; +import { describeScope, parseScope, validEmail } from '../subscribe'; -export const handle = { topics: ['settings', 'errors'] }; +export const handle = { topics: ['settings', 'errors', 'subscribers'] }; function owner(hub: Hub) { if (!hub.viewer?.owner) { @@ -38,6 +40,7 @@ export function loader({ context, request }: Route.LoaderArgs) { errors: hub.admin.errors(), waiting: hub.messages.queue().length, history: key && isSettingKey(key) ? { key, versions: hub.admin.history(key) } : null, + email: hub.subscribers.overview(), }; } @@ -67,6 +70,17 @@ export async function action({ context, request }: Route.ActionArgs) { break; case 'sign-out-everyone': return { ok: `${await hub.admin.signOutEveryone(viewer.login)} sessions ended.` }; + case 'test-email': { + const to = String(form.get('to') ?? '') + .trim() + .toLowerCase(); + const kind = String(form.get('kind')); + if (!validEmail(to) || !['confirm', 'post', 'digest'].includes(kind)) { + return data({ error: 'Type an address to send the test to.', key }, { status: 400 }); + } + await hub.subscribers.sendTest(kind as 'confirm' | 'post' | 'digest', to); + return { ok: `${kind} sent to ${to}`, key: 'test-email' }; + } } } catch (error) { return data( @@ -77,7 +91,13 @@ export async function action({ context, request }: Route.ActionArgs) { return { ok: 'saved', key }; } -const AREAS: Record = { site: 'site', home: 'home page', rail: 'rail', blog: 'blog' }; +const AREAS: Record = { + site: 'site', + home: 'home page', + rail: 'rail', + blog: 'blog', + email: 'email', +}; type Versions = NonNullable['versions']; @@ -221,8 +241,21 @@ function ColorsControl({ value: colours, commit }: ControlProps) { + return ( + [option, option] as const)} + value={value} + choose={commit} + label={setting.label} + /> + ); +} + function Control(props: ControlProps) { switch (props.setting.kind) { + case 'choice': + return )} />; case 'text': return )} />; case 'number': @@ -411,6 +444,133 @@ function Preview() { ); } +const EMAIL_KINDS = [ + ['post', 'new post'], + ['digest', 'digest'], + ['confirm', 'confirmation'], +] as const; + +function EmailStudio({ csrf, testTo }: { csrf: string; testTo: string }) { + const settings = useRoot()!.settings; + const [kind, setKind] = useState<(typeof EMAIL_KINDS)[number][0]>('post'); + const test = useFetcher(); + const version = JSON.stringify( + Object.entries(settings).filter(([key]) => key.startsWith('email.') || key === 'site.name'), + ); + const result = + test.data && 'key' in test.data && test.data.key === 'test-email' ? test.data : null; + return ( +
+
+
+ + + + +
+