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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/app/components/AccountMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] : []),
]
: [];
Expand Down
18 changes: 13 additions & 5 deletions site/app/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -81,11 +82,18 @@ export default function Shell({ children }: { children: ReactNode }) {
</main>

<footer className="frame-footer">
<div className="shell flex flex-wrap justify-between gap-4">
<Editable setting="site.footer" />
<Link to="/privacy" className="hover:text-ink">
privacy
</Link>
<div className="shell footer-grid">
<div className="footer-subscribe">
<span className="footer-lead">new writing, by email</span>
<SubscribeForm scope={{ kind: 'all' }} cadence="each" compact />
<SubscribeLink />
</div>
<div className="footer-line">
<Editable setting="site.footer" />
<Link to="/privacy" className="hover:text-ink">
privacy
</Link>
</div>
</div>
</footer>
<Palette />
Expand Down
219 changes: 219 additions & 0 deletions site/app/components/kit/subscribe.tsx
Original file line number Diff line number Diff line change
@@ -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<Outcome>();
const magnetic = useMagnetic<HTMLButtonElement>();
const id = useId();
const sent = fetcher.data && 'sent' in fetcher.data;
const reason = fetcher.data && 'reason' in fetcher.data ? fetcher.data.reason : null;
return (
<fetcher.Form
method="post"
action={action}
className={`subscribe-form ${compact ? 'compact' : ''}`}
data-sent={sent || undefined}
>
<input type="hidden" name="intent" value="subscribe" />
<input type="hidden" name="scope" value={scopeKey(scope)} />
<input type="hidden" name="cadence" value={cadence} />
<input
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute -left-[9999px]"
/>
{sent ? (
<p className="sent">
<i />
check your inbox to confirm
</p>
) : (
<>
<label htmlFor={id} className="sr-only">
Email address
</label>
<input
id={id}
type="email"
name="email"
required
maxLength={254}
autoComplete="email"
placeholder="you@somewhere.com"
className="field"
/>
<button
ref={magnetic}
type="submit"
className="btn primary"
disabled={fetcher.state !== 'idle'}
>
subscribe <span className="arrow">→</span>
</button>
</>
)}
{reason && <p className="mono-sm text-danger reason">{reason}</p>}
</fetcher.Form>
);
}

export function SubscribePanel({ scope, title = 'by email' }: { scope: Scope; title?: string }) {
const hub = useHub();
return (
<Panel title={title} link={{ to: '/subscribe', label: 'choose' }}>
<p className="intro">{describeChoice(scope, 'each', hub)}, in your inbox.</p>
<SubscribeForm scope={scope} cadence="each" compact />
</Panel>
);
}

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 (
<div className="chooser">
<div className="kinds" role="group" aria-label="What to follow">
{KINDS.map(([kind, label]) => (
<button
key={kind}
type="button"
className="kind-card"
aria-pressed={scope.kind === kind}
disabled={
(kind === 'project' && hub.projects.length === 0) ||
(kind === 'member' && hub.members.length === 0)
}
onClick={() => pickKind(kind)}
>
<span className="glyph" aria-hidden="true">
{kind === 'all' ? '∗' : kind === 'project' ? '▣' : '◉'}
</span>
<strong>{label}</strong>
</button>
))}
</div>
{scope.kind === 'project' && (
<Chips
label="Which project"
value={scope.slug}
onChange={(slug) => {
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' && (
<div className="faces" role="group" aria-label="Which member">
{hub.members.map((member) => (
<button
key={member.handle}
type="button"
className="face-pick"
aria-pressed={scope.handle === member.handle}
aria-label={member.name}
style={accentStyle(member.accent)}
onClick={() => {
setLastMember(member.handle);
onScope({ kind: 'member', handle: member.handle });
}}
>
<Avatar member={member} size="md" />
<span>{member.name}</span>
</button>
))}
</div>
)}
<div className="cadence">
<span className="choice" role="group" aria-label="How often">
<label>
<input
type="radio"
name="cadence-pick"
value="each"
checked={cadence === 'each'}
onChange={() => onCadence('each')}
/>
each post
</label>
<label>
<input
type="radio"
name="cadence-pick"
value="weekly"
checked={cadence === 'weekly'}
onChange={() => onCadence('weekly')}
/>
weekly digest
</label>
</span>
<p key={sentence} className="sentence">
You will get <b>{sentence}</b>.
</p>
</div>
</div>
);
}

export function SubscribeLink() {
return (
<Link to="/subscribe" className="rule-link mono-sm">
choose what you get
</Link>
);
}
2 changes: 2 additions & 0 deletions site/app/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -26,6 +27,7 @@ export interface Hub {
writeback: Writeback;
uploads: Uploads;
posts: Posts;
subscribers: Subscribers;
collab: Collab;
address: string;
origin: string;
Expand Down
17 changes: 16 additions & 1 deletion site/app/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface PageMeta {
title: string;
description: string;
canonical: string;
noindex?: boolean;
}

export const INDEX: PageMeta = {
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 },
Expand Down
5 changes: 5 additions & 0 deletions site/app/pages/member-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -116,6 +117,10 @@ export function MemberPage({ data }: { data: ReturnType<typeof loadMember> }) {
/>
)}
{data.running.length > 0 && <RunningPanel running={data.running} />}
<SubscribePanel
scope={{ kind: 'member', handle: member.handle }}
title={`follow ${member.name.split(' ')[0]}`}
/>
</>
}
>
Expand Down
5 changes: 5 additions & 0 deletions site/app/pages/project-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,6 +71,10 @@ export function ProjectPage({ data: loaderData }: { data: ReturnType<typeof load
releases={releases.slice(0, settings['rail.changelog.count'] + 1)}
projectOf={(release) => projectBySlug(release.project)}
/>
<SubscribePanel
scope={{ kind: 'project', slug: project.slug }}
title={`follow ${project.name}`}
/>
{authors.length > 0 && (
<Panel title="built by">
<div className="panel-list">
Expand Down
2 changes: 2 additions & 0 deletions site/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading