diff --git a/packages/shared/src/components/archive/ArchiveIndexPage.tsx b/packages/shared/src/components/archive/ArchiveIndexPage.tsx
index 46ab41643b3..f1e6f77dce0 100644
--- a/packages/shared/src/components/archive/ArchiveIndexPage.tsx
+++ b/packages/shared/src/components/archive/ArchiveIndexPage.tsx
@@ -2,6 +2,7 @@ import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { Archive } from '../../graphql/archive';
+import { ArchiveScopeType } from '../../graphql/archive';
import type { ArchiveScopeInfo, ArchivesByYear } from '../../lib/archive';
import {
getArchiveUrlFromArchive,
@@ -13,6 +14,9 @@ import Link from '../utilities/Link';
import { ArrowIcon } from '../icons';
import { IconSize } from '../Icon';
import { ElementPlaceholder } from '../ElementPlaceholder';
+import { CopyLinkButton } from '../share/CopyLinkButton';
+import { LogEvent } from '../../lib/log';
+import { ReferralCampaignKey } from '../../lib/referral';
interface ArchiveIndexPageProps {
scopeType: ArchiveScopeInfo['scopeType'];
@@ -159,13 +163,31 @@ export function ArchiveIndexPage({
className,
}: ArchiveIndexPageProps): ReactElement {
const groups = groupArchivesByYear(archives);
+ const isTagScope = scopeType === ArchiveScopeType.Tag;
+ const shareCampaign = isTagScope
+ ? ReferralCampaignKey.ShareTag
+ : ReferralCampaignKey.ShareSource;
+ const shareEvent = isTagScope ? LogEvent.ShareTag : LogEvent.ShareSource;
return (
{/* Header */}
-
- Best of {scopeName} — Archive
-
+
+
+ Best of {scopeName} — Archive
+
+ ({
+ event_name: shareEvent,
+ target_id: scopeId,
+ }),
+ }}
+ />
+
{/* Archive grid by year */}
+ {/* Hover-revealed only where hover exists; always there on touch. */}
+ ({
+ event_name: LogEvent.ShareSource,
+ target_id: item.id,
+ }),
+ }}
+ />
))}
diff --git a/packages/shared/src/components/cards/entity/SquadEntityCard.tsx b/packages/shared/src/components/cards/entity/SquadEntityCard.tsx
index 383b85ece75..a386218abcf 100644
--- a/packages/shared/src/components/cards/entity/SquadEntityCard.tsx
+++ b/packages/shared/src/components/cards/entity/SquadEntityCard.tsx
@@ -7,13 +7,16 @@ import {
TypographyType,
} from '../../typography/Typography';
import type { Origin } from '../../../lib/log';
+import { LogEvent } from '../../../lib/log';
import { largeNumberFormat } from '../../../lib';
import { SquadActionButton } from '../../squads/SquadActionButton';
import { SourceIcon } from '../../icons';
import { IconSize } from '../../Icon';
import { useSquad } from '../../../hooks';
-import { ButtonSize } from '../../buttons/Button';
+import { ButtonSize, ButtonVariant } from '../../buttons/Button';
import SquadHeaderMenu from '../../squads/SquadHeaderMenu';
+import { CopyLinkButton } from '../../share/CopyLinkButton';
+import { ReferralCampaignKey } from '../../../lib/referral';
import { Separator } from '../common/common';
import EntityDescription from './EntityDescription';
import EntityCard from './EntityCard';
@@ -59,6 +62,18 @@ const SquadEntityCard = ({
actionButtons={
!isLoading && (
<>
+ ({
+ event_name: LogEvent.ShareSource,
+ target_id: squad.id,
+ }),
+ }}
+ />
{
+ const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps);
+
+ return (
+
+ }
+ onClick={() => onShareOrCopyLink()}
+ size={size}
+ variant={variant}
+ />
+
+ );
+};
diff --git a/packages/shared/src/components/sources/SourceActions/index.tsx b/packages/shared/src/components/sources/SourceActions/index.tsx
index 266ef8d4c31..dad7169f135 100644
--- a/packages/shared/src/components/sources/SourceActions/index.tsx
+++ b/packages/shared/src/components/sources/SourceActions/index.tsx
@@ -8,9 +8,11 @@ import SourceActionsNotify from './SourceActionsNotify';
import SourceActionsBlock from './SourceActionsBlock';
import SourceActionsFollow from './SourceActionsFollow';
import CustomFeedOptionsMenu from '../../CustomFeedOptionsMenu';
+import { CopyLinkButton } from '../../share/CopyLinkButton';
import { LogEvent } from '../../../lib/log';
import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference';
import { ContentPreferenceType } from '../../../graphql/contentPreference';
+import type { ContentPreferenceMutation } from '../../../hooks/contentPreference/types';
interface SourceActionsButton {
className?: string;
@@ -25,6 +27,7 @@ export interface SourceActionsProps {
hideFollow?: boolean;
notifyProps?: SourceActionsButton;
hideNotify?: boolean;
+ showCopyLink?: boolean;
}
export const SourceActions = ({
@@ -35,6 +38,7 @@ export const SourceActions = ({
hideNotify = false,
source,
notifyProps,
+ showCopyLink = false,
}: SourceActionsProps): ReactElement => {
const {
isBlocked,
@@ -49,6 +53,32 @@ export const SourceActions = ({
const { follow, unfollow } = useContentPreference();
const router = useRouter();
+ const updateCustomFeed = (
+ mutate: ContentPreferenceMutation,
+ feedId: string,
+ ) => {
+ if (!source.id) {
+ throw new Error('Cannot update a custom feed for a source without an id');
+ }
+
+ return mutate({
+ id: source.id,
+ entity: ContentPreferenceType.Source,
+ entityName: source.handle,
+ feedId,
+ });
+ };
+
+ const shareProps = {
+ text: `Check out ${source.handle} on daily.dev`,
+ link: source.permalink,
+ cid: ReferralCampaignKey.ShareSource,
+ logObject: () => ({
+ event_name: LogEvent.ShareSource,
+ target_id: source.id,
+ }),
+ };
+
return (
{!hideFollow && !isBlocked && (
@@ -74,37 +104,16 @@ export const SourceActions = ({
{...blockProps}
/>
)}
+ {showCopyLink && }
router.push(
`/feeds/new?entityId=${source.id}&entityType=${ContentPreferenceType.Source}`,
)
}
- onAdd={(feedId) =>
- follow({
- id: source.id,
- entity: ContentPreferenceType.Source,
- entityName: source.handle,
- feedId,
- })
- }
- onUndo={(feedId) =>
- unfollow({
- id: source.id,
- entity: ContentPreferenceType.Source,
- entityName: source.handle,
- feedId,
- })
- }
- shareProps={{
- text: `Check out ${source.handle} on daily.dev`,
- link: source.permalink,
- cid: ReferralCampaignKey.ShareSource,
- logObject: () => ({
- event_name: LogEvent.ShareSource,
- target_id: source.id,
- }),
- }}
+ onAdd={(feedId) => updateCustomFeed(follow, feedId)}
+ onUndo={(feedId) => updateCustomFeed(unfollow, feedId)}
+ shareProps={shareProps}
/>
);
diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx
index 4c4dc241eec..c4249d7ffae 100644
--- a/packages/shared/src/components/tags/TagTopicPage.tsx
+++ b/packages/shared/src/components/tags/TagTopicPage.tsx
@@ -49,6 +49,7 @@ import { cloudinarySourceRoadmap } from '../../lib/image';
import { anchorDefaultRel, formatKeyword } from '../../lib/strings';
import Link from '../utilities/Link';
import CustomFeedOptionsMenu from '../CustomFeedOptionsMenu';
+import { CopyLinkButton } from '../share/CopyLinkButton';
import { ArchiveEntryCard } from '../archive/ArchiveEntryCard';
import { ArchiveScopeType } from '../../graphql/archive';
import { useContentPreference } from '../../hooks/contentPreference/useContentPreference';
@@ -356,6 +357,16 @@ export const TagTopicPage = ({
},
});
+ const shareProps = {
+ text: `Check out the ${tag} tag on daily.dev`,
+ link: globalThis?.location?.href,
+ cid: ReferralCampaignKey.ShareTag,
+ logObject: () => ({
+ event_name: LogEvent.ShareTag,
+ target_id: tag,
+ }),
+ };
+
const statParts: ReactNode[] = [];
if (typeof followers === 'number') {
statParts.push(
@@ -447,6 +458,7 @@ export const TagTopicPage = ({
{tagStatus === 'blocked' ? 'Unblock' : 'Block'}
)}
+
push(
@@ -469,15 +481,7 @@ export const TagTopicPage = ({
feedId,
})
}
- shareProps={{
- text: `Check out the ${tag} tag on daily.dev`,
- link: globalThis?.location?.href,
- cid: ReferralCampaignKey.ShareTag,
- logObject: () => ({
- event_name: LogEvent.ShareTag,
- target_id: tag,
- }),
- }}
+ shareProps={shareProps}
/>
{/* SEO crawl paths preserved from the legacy tag page. */}
diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx
new file mode 100644
index 00000000000..d4654bca9ff
--- /dev/null
+++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { LinkIcon } from '@dailydotdev/shared/src/components/icons';
+
+export const AVATAR =
+ 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile';
+
+
+/* ------------------------------------------------------------------ prose */
+
+export const H1 = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+export const P = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+export const Note = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+/* ---------------------------------------------------------------- controls */
+
+/**
+ * Inert on purpose: this page compares where a control sits inside a real
+ * screen. The working buttons and live capture are on Button placements.
+ */
+export const Control = () => (
+ }
+ size={ButtonSize.Small}
+ variant={ButtonVariant.Tertiary}
+ />
+);
+
+export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile';
+
+/**
+ * Breakpoints matter more than usual here. PostSourceInfo renders the whole
+ * header cluster as `hidden laptop:flex`, so the ⋯ menu that carries sharing
+ * on desktop is simply not in the article header below 1020px — it moves to a
+ * sticky back-bar, and a floating action bar appears at the bottom. A
+ * recommendation that only works on one of the three is not a recommendation.
+ */
+export const DEVICES: Record<
+ DeviceName,
+ { width: number; viewport: string }
+> = {
+ Desktop: { width: 680, viewport: '1020px and up' },
+ Tablet: { width: 560, viewport: '768px' },
+ Mobile: { width: 375, viewport: '375px' },
+};
+
+/** A surface drawn at one real viewport width, so density is comparable. */
+export const Device = ({
+ name,
+ children,
+ height,
+}: {
+ name: DeviceName;
+ children: React.ReactNode;
+ /** Mobile surfaces pin a floating bar, so the frame needs a known height. */
+ height?: number;
+}) => (
+
+
+ {name} · {DEVICES[name].viewport}
+
+
+ {children}
+
+
+);
+
+/** Devices sit in a scroller rather than wrapping, so widths stay honest. */
+export const Rail = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+export const Variant = ({
+ step,
+ headline,
+ note,
+ children,
+}: {
+ step: string;
+ headline: string;
+ note: string;
+ children: React.ReactNode;
+}) => (
+ // Full width so a device rail can scroll across the whole canvas.
+
+
+
+ {step}
+
+
+ {headline}
+
+ {note}
+
+ {children}
+
+);
+
+export const Category = ({
+ title,
+ covers,
+ verdict,
+ children,
+}: {
+ title: string;
+ covers: string;
+ verdict: string;
+ children: React.ReactNode;
+}) => (
+
+
+
{title}
+
{covers}
+
+ {verdict}
+
+
+ {children}
+
+);
+
+/** Every category page opens with the same header, so they read as a set. */
+export const SurfacePage = ({
+ title,
+ intro,
+ map,
+ children,
+}: {
+ title: string;
+ intro: string;
+ map: string;
+ children: React.ReactNode;
+}) => (
+
+
+
{title}
+
{intro}
+
{map}
+
+ {children}
+
+);
diff --git a/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx
new file mode 100644
index 00000000000..ea204241265
--- /dev/null
+++ b/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx
@@ -0,0 +1,323 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import {
+ BellIcon,
+ BlockIcon,
+ MenuIcon,
+} from '@dailydotdev/shared/src/components/icons';
+import type { DeviceName } from '../surfaceChrome';
+import {
+ AVATAR,
+ Category,
+ Control,
+ Device,
+ Rail,
+ SurfacePage,
+ Variant,
+} from '../surfaceChrome';
+
+const Options = () => (
+ }
+ size={ButtonSize.Small}
+ variant={ButtonVariant.Tertiary}
+ />
+);
+
+/* ------------------------------------------------------------------- tags */
+
+/** TagTopicPage: a centred hero, not a row. No logo, no avatar. */
+const TagScreen = ({ device }: { device: DeviceName }) => (
+
+ {/* TagPageNavbar — full-bleed strip of related tags. */}
+
+ {['typescript', 'javascript', 'react', 'node', 'webdev'].map(
+ (tag, index) => (
+
+ #{tag}
+
+ ),
+ )}
+
+
+
+
+ TypeScript
+
+
+ Tag
+ ·
+ 48.2K followers
+ ·
+ 12.4K stories
+
+
+ A typed superset of JavaScript that compiles to plain JavaScript.
+
+
+
+ Follow
+
+ }
+ size={ButtonSize.Small}
+ variant={ButtonVariant.Float}
+ >
+ Block
+
+
+
+
+
+
+);
+
+/* ---------------------------------------------------------------- sources */
+
+/** The source page is left-aligned, and its actions sit below the title. */
+const SourceScreen = ({ device }: { device: DeviceName }) => (
+
+
+
+ Sources / XDA Developers
+
+
+
+
+
+ XDA Developers
+
+
+
+
+
+ Follow
+
+ }
+ size={ButtonSize.Small}
+ variant={ButtonVariant.Float}
+ />
+
+
+
+
+
+ News and reviews for developers, by developers.
+
+
+
+ {['#android', '#hardware', '#reviews'].map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+);
+
+/* ----------------------------------------------------------------- squads */
+
+/** SquadEntityCard: w-80, image and actions on one row, body under it. */
+const SquadCard = () => (
+
+
+
+
+
+
+ Join
+
+ {/* SquadHeaderMenu — `invisible group-hover/menu:visible`. */}
+
+
+
+
+
+ Frontend Fans
+
+
+ Everything CSS, React and the browser. Ship it and show it.
+
+
+ 2.4K Members
+ ·
+ 12K Upvotes
+
+
+
+);
+
+const SquadScreen = ({ device }: { device: DeviceName }) => (
+
+
+
Squads
+
+
+ {device === 'Desktop' && }
+
+
+
+);
+
+/* ---------------------------------------------------------------- archive */
+
+/** ArchiveIndexPage: a month grid, no posts and no feed controls. */
+const ArchiveScreen = ({ device }: { device: DeviceName }) => (
+
+
+
+ Sources / XDA Developers / Best of
+
+
+
+ Best of XDA Developers — Archive
+
+
+
+
+
+ {['2026', '2025'].map((year) => (
+
+
+ {year}
+
+
+ {['January', 'February', 'March', 'April'].map((month) => (
+
+
+ {month}
+
+
+ 24 posts
+
+
+ ))}
+
+
+ ))}
+
+
+
+);
+
+/* -------------------------------------------------------------------- page */
+
+const Rails = ({
+ Screen,
+}: {
+ Screen: React.ComponentType<{ device: DeviceName }>;
+}) => (
+
+
+
+
+
+);
+
+const Directories = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const meta: Meta = {
+ title: 'Features/Snapshot/Surfaces/Topic & directory pages',
+ component: Directories,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+export const Variations: StoryObj = {};
diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx
index baab4eadec0..4a48fc4e6f5 100644
--- a/packages/webapp/pages/sources/[source].tsx
+++ b/packages/webapp/pages/sources/[source].tsx
@@ -301,7 +301,7 @@ const SourcePage = ({
{source.name}
-
+
{source?.description && (
{source?.description}