From 04c5cd99b064faa1c98bea3ab2fd1eb6d480a330 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:36:58 +0300 Subject: [PATCH 1/9] feat(shared): capture an element as a branded share image Adds a Snapshot control that rasterizes any element with snapdom, fits it inside a 1200x630 frame on the current theme's background and draws the daily.dev logo bar. The PNG goes to the clipboard, because a paste beats a file in Downloads for every place we share to, and falls back to a download where ClipboardItem is unavailable. A cross-origin image without CORS headers leaves snapdom's inliner pending forever, so the capture times out rather than spinning the button. Co-Authored-By: Claude Opus 5 --- packages/shared/package.json | 1 + .../src/components/icons/Snapshot/filled.svg | 13 ++ .../src/components/icons/Snapshot/index.tsx | 10 + .../components/icons/Snapshot/outlined.svg | 11 + packages/shared/src/components/icons/index.ts | 1 + .../imageShare/SnapshotButton.spec.tsx | 110 ++++++++++ .../components/imageShare/SnapshotButton.tsx | 126 +++++++++++ .../src/features/snapshot/shutterSound.ts | 23 ++ .../src/lib/imageShare/captureShareImage.ts | 207 ++++++++++++++++++ .../src/lib/imageShare/copyShareImage.ts | 19 ++ .../src/lib/imageShare/downloadShareImage.ts | 10 + packages/shared/src/styles/utilities.css | 42 ++++ packages/webapp/public/sounds/shutter.mp3 | Bin 0 -> 45824 bytes pnpm-lock.yaml | 15 +- 14 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/components/icons/Snapshot/filled.svg create mode 100644 packages/shared/src/components/icons/Snapshot/index.tsx create mode 100644 packages/shared/src/components/icons/Snapshot/outlined.svg create mode 100644 packages/shared/src/components/imageShare/SnapshotButton.spec.tsx create mode 100644 packages/shared/src/components/imageShare/SnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/shutterSound.ts create mode 100644 packages/shared/src/lib/imageShare/captureShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/copyShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/downloadShareImage.ts create mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/package.json b/packages/shared/package.json index 00e6ef543b4..02c0664bf98 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -125,6 +125,7 @@ "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "@zumer/snapdom": "^2.23.1", "border-beam": "1.3.0", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", diff --git a/packages/shared/src/components/icons/Snapshot/filled.svg b/packages/shared/src/components/icons/Snapshot/filled.svg new file mode 100644 index 00000000000..d4cc05f0b56 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/filled.svg @@ -0,0 +1,13 @@ + + + Icon/Snapshot/Filled + + + + + + + + + + diff --git a/packages/shared/src/components/icons/Snapshot/index.tsx b/packages/shared/src/components/icons/Snapshot/index.tsx new file mode 100644 index 00000000000..8707b229fad --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const SnapshotIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Snapshot/outlined.svg b/packages/shared/src/components/icons/Snapshot/outlined.svg new file mode 100644 index 00000000000..af265154e03 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/outlined.svg @@ -0,0 +1,11 @@ + + + Icon/Snapshot/Outline + + + + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 52ee9458013..5c1057b1724 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -150,6 +150,7 @@ export * from './Shortcuts'; export * from './Sidebar'; export * from './Sites'; export * from './Slack'; +export * from './Snapshot'; export * from './Sort'; export * from './Source'; export * from './Sparkle'; diff --git a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx new file mode 100644 index 00000000000..57c00d50959 --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { SnapshotButton } from './SnapshotButton'; + +const mockCapture = jest.fn(); +const mockCopy = jest.fn(); +const mockDownload = jest.fn(); +const mockDisplayToast = jest.fn(); + +jest.mock('../../lib/imageShare/captureShareImage', () => ({ + captureShareImage: (...args: unknown[]) => mockCapture(...args), +})); + +jest.mock('../../lib/imageShare/copyShareImage', () => ({ + copyShareImage: (...args: unknown[]) => mockCopy(...args), +})); + +jest.mock('../../lib/imageShare/downloadShareImage', () => ({ + downloadShareImage: (...args: unknown[]) => mockDownload(...args), +})); + +jest.mock('../../features/snapshot/shutterSound', () => ({ + playShutterSound: jest.fn(), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), + ToastType: { Success: 'success', Error: 'error' }, +})); + +jest.mock('../../hooks/useRequestProtocol', () => ({ + useRequestProtocol: () => ({ isCompanion: false }), +})); + +const blob = new Blob(['png'], { type: 'image/png' }); + +const renderComponent = (props = {}) => { + const target = document.createElement('div'); + + return render( + , + ); +}; + +const clickSnapshot = () => + fireEvent.click(screen.getByLabelText('Snapshot'), { + preventDefault: jest.fn(), + }); + +beforeEach(() => { + jest.clearAllMocks(); + mockCapture.mockResolvedValue(blob); +}); + +it('copies the image and says so', async () => { + mockCopy.mockResolvedValue(true); + renderComponent(); + + clickSnapshot(); + + await waitFor(() => + expect(mockDisplayToast).toHaveBeenCalledWith('Image copied', { + variant: 'success', + }), + ); + expect(mockDownload).not.toHaveBeenCalled(); +}); + +it('falls back to a download when the clipboard is unavailable', async () => { + mockCopy.mockResolvedValue(false); + renderComponent({ filename: 'daily-profile-tomer' }); + + clickSnapshot(); + + await waitFor(() => + expect(mockDownload).toHaveBeenCalledWith(blob, 'daily-profile-tomer'), + ); + expect(mockDisplayToast).toHaveBeenCalledWith('Image saved', { + variant: 'success', + }); +}); + +it('reports a failed capture instead of copying or downloading', async () => { + mockCapture.mockRejectedValue(new Error('target element has no size')); + mockCopy.mockResolvedValue(false); + renderComponent(); + + clickSnapshot(); + + await waitFor(() => + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Could not create the snapshot, please try again', + { variant: 'error' }, + ), + ); + expect(mockDownload).not.toHaveBeenCalled(); +}); + +it('hands the blob to onCapture instead of sharing it', async () => { + const onCapture = jest.fn(); + mockCopy.mockResolvedValue(true); + renderComponent({ onCapture }); + + clickSnapshot(); + + await waitFor(() => expect(onCapture).toHaveBeenCalledWith(blob)); + expect(mockCopy).not.toHaveBeenCalled(); + expect(mockDownload).not.toHaveBeenCalled(); + expect(mockDisplayToast).not.toHaveBeenCalled(); +}); diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx new file mode 100644 index 00000000000..e9a8e000d7d --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -0,0 +1,126 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { SnapshotIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { + ToastType, + useToastNotification, +} from '../../hooks/useToastNotification'; +import type { + CaptureShareImageOptions, + CaptureTarget, +} from '../../lib/imageShare/captureShareImage'; +import { captureShareImage } from '../../lib/imageShare/captureShareImage'; +import { downloadShareImage } from '../../lib/imageShare/downloadShareImage'; +import { copyShareImage } from '../../lib/imageShare/copyShareImage'; +import { playShutterSound } from '../../features/snapshot/shutterSound'; + +const SNAPSHOT_LABEL = 'Snapshot'; + +/** Matches the snapshot-shutter-sweep animation in utilities.css. */ +const SHUTTER_SWEEP_MS = 380; + +export interface SnapshotButtonProps { + target: CaptureTarget; + filename?: string; + label?: string; + showLabel?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; + className?: string; + captureOptions?: CaptureShareImageOptions; + onCapture?: (blob: Blob) => void; +} + +export function SnapshotButton({ + target, + filename = 'daily-snapshot', + label = SNAPSHOT_LABEL, + showLabel = true, + captureOptions, + onCapture, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, + className, +}: SnapshotButtonProps): ReactElement { + const { displayToast } = useToastNotification(); + const [isCapturing, setIsCapturing] = useState(false); + const [isFlashing, setIsFlashing] = useState(false); + const flashTimeout = useRef>(); + + useEffect( + () => () => { + if (flashTimeout.current) { + clearTimeout(flashTimeout.current); + } + }, + [], + ); + + const onSnapshot = useCallback( + async (event: React.MouseEvent) => { + // Every placement sits inside a clickable card, row or link. + event.preventDefault(); + event.stopPropagation(); + playShutterSound(); + setIsFlashing(true); + flashTimeout.current = setTimeout( + () => setIsFlashing(false), + SHUTTER_SWEEP_MS, + ); + setIsCapturing(true); + + try { + const capture = captureShareImage(target, captureOptions); + + if (onCapture) { + onCapture(await capture); + return; + } + + // Pasting beats a file in Downloads for every target we share to, so + // the clipboard leads and the download is the fallback. + if (await copyShareImage(capture)) { + displayToast('Image copied', { variant: ToastType.Success }); + return; + } + + downloadShareImage(await capture, filename); + displayToast('Image saved', { variant: ToastType.Success }); + } catch { + displayToast('Could not create the snapshot, please try again', { + variant: ToastType.Error, + }); + } finally { + setIsCapturing(false); + } + }, + [captureOptions, displayToast, filename, onCapture, target], + ); + + return ( + + + + ); +} diff --git a/packages/shared/src/features/snapshot/shutterSound.ts b/packages/shared/src/features/snapshot/shutterSound.ts new file mode 100644 index 00000000000..ac00c91412d --- /dev/null +++ b/packages/shared/src/features/snapshot/shutterSound.ts @@ -0,0 +1,23 @@ +import { fromCDN } from '../../lib/links'; + +let shutter: HTMLAudioElement | null = null; + +/** + * One shared element rather than one per press: rewinding an existing clip is + * instant, while a fresh Audio has to fetch and decode before it plays. + */ +export function playShutterSound(): void { + if (typeof window === 'undefined') { + return; + } + + if (!shutter) { + shutter = new Audio(fromCDN('/sounds/shutter.mp3')); + shutter.volume = 0.45; + } + + shutter.currentTime = 0; + // Autoplay policy rejects until the page has been interacted with, and the + // capture must not fail because the sound did. + shutter.play().catch(() => {}); +} diff --git a/packages/shared/src/lib/imageShare/captureShareImage.ts b/packages/shared/src/lib/imageShare/captureShareImage.ts new file mode 100644 index 00000000000..0365ec1b77e --- /dev/null +++ b/packages/shared/src/lib/imageShare/captureShareImage.ts @@ -0,0 +1,207 @@ +import type { RefObject } from 'react'; +import { createElement } from 'react'; +import type { SnapdomOptions } from '@zumer/snapdom'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; + +export const SHARE_IMAGE_WIDTH = 1200; +export const SHARE_IMAGE_HEIGHT = 630; + +const LOGO_BAR_HEIGHT = 72; +const LOGO_BAR_BORDER = 2; +const LOGO_HEIGHT = 26; +const LOGO_GAP = 8; +const LOGO_ICON_RATIO = 35 / 20; +const LOGO_TEXT_RATIO = 77 / 20; + +export type CaptureTarget = HTMLElement | RefObject; + +export interface CaptureShareImageOptions extends SnapdomOptions { + width?: number; + height?: number; + padding?: number; + frameBackgroundColor?: string; + branded?: boolean; +} + +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; +const CAPTURE_TIMEOUT_MS = 15000; + +// A cross-origin image without CORS headers leaves snapdom's inliner pending +// forever, which would otherwise spin the trigger button indefinitely. +const withTimeout = (promise: Promise): Promise => + Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error('captureShareImage: capture timed out')), + CAPTURE_TIMEOUT_MS, + ); + }), + ]); + +const resolveFrameBackground = (): string => { + const rootStyle = getComputedStyle(document.documentElement); + const rootBackground = rootStyle.backgroundColor; + + if (rootBackground && rootBackground !== TRANSPARENT) { + return rootBackground; + } + + const themeBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + + if (themeBackground) { + return themeBackground; + } + + return getComputedStyle(document.body).backgroundColor; +}; + +const svgToImage = async (markup: string): Promise => { + const image = new Image(); + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`; + await image.decode(); + + return image; +}; + +const drawLogoBar = async ( + context: CanvasRenderingContext2D, + canvasWidth: number, + canvasHeight: number, +): Promise => { + const { renderToStaticMarkup } = await import('react-dom/server'); + const rootStyle = getComputedStyle(document.documentElement); + const themeColor = rootStyle.getPropertyValue('--theme-text-primary').trim(); + const color = themeColor || getComputedStyle(document.body).color; + const barBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + const barBorder = rootStyle + .getPropertyValue('--theme-border-subtlest-tertiary') + .trim(); + + const barTop = canvasHeight - LOGO_BAR_HEIGHT; + + if (barBackground) { + context.fillStyle = barBackground; + context.fillRect(0, barTop, canvasWidth, LOGO_BAR_HEIGHT); + } + + if (barBorder) { + context.fillStyle = barBorder; + context.fillRect(0, barTop, canvasWidth, LOGO_BAR_BORDER); + } + + const toSizedMarkup = (markup: string, width: number): string => + markup + .replace(' { + const element = target instanceof HTMLElement ? target : target.current; + + if (!element) { + throw new Error('captureShareImage: target element is not mounted'); + } + + const { + width = SHARE_IMAGE_WIDTH, + height = SHARE_IMAGE_HEIGHT, + padding = 48, + frameBackgroundColor, + branded = true, + ...snapOptions + } = options; + const barHeight = branded ? LOGO_BAR_HEIGHT : 0; + const contentWidth = width - padding * 2; + const contentHeight = height - padding * 2 - barHeight; + + const rect = element.getBoundingClientRect(); + + if (!rect.width || !rect.height) { + throw new Error('captureShareImage: target element has no size'); + } + + const fitScale = Math.min( + contentWidth / rect.width, + contentHeight / rect.height, + ); + const captureScale = Math.max(1, fitScale); + + const { snapdom } = await import('@zumer/snapdom'); + const result = await withTimeout( + snapdom(element, { + embedFonts: true, + scale: captureScale, + ...snapOptions, + }), + ); + const source = await result.toCanvas(); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('captureShareImage: canvas 2d context unavailable'); + } + + context.fillStyle = frameBackgroundColor ?? resolveFrameBackground(); + context.fillRect(0, 0, canvas.width, canvas.height); + + const drawScale = Math.min( + contentWidth / source.width, + contentHeight / source.height, + ); + const drawWidth = source.width * drawScale; + const drawHeight = source.height * drawScale; + + context.imageSmoothingQuality = 'high'; + context.drawImage( + source, + (canvas.width - drawWidth) / 2, + padding + (contentHeight - drawHeight) / 2, + drawWidth, + drawHeight, + ); + + if (branded) { + await drawLogoBar(context, width, height); + } + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('captureShareImage: failed to encode PNG')); + } + }, 'image/png'); + }); +} diff --git a/packages/shared/src/lib/imageShare/copyShareImage.ts b/packages/shared/src/lib/imageShare/copyShareImage.ts new file mode 100644 index 00000000000..a712696ceef --- /dev/null +++ b/packages/shared/src/lib/imageShare/copyShareImage.ts @@ -0,0 +1,19 @@ +/** + * Puts the PNG on the clipboard so it can be pasted straight into a chat or a + * composer. Safari only honours a clipboard write inside the task that handled + * the gesture, so the blob is handed over as a promise rather than awaited + * first — `ClipboardItem` resolves it without losing the gesture. + */ +export async function copyShareImage(blob: Promise): Promise { + if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { + return false; + } + + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + + return true; + } catch { + return false; + } +} diff --git a/packages/shared/src/lib/imageShare/downloadShareImage.ts b/packages/shared/src/lib/imageShare/downloadShareImage.ts new file mode 100644 index 00000000000..e4d411d267d --- /dev/null +++ b/packages/shared/src/lib/imageShare/downloadShareImage.ts @@ -0,0 +1,10 @@ +export function downloadShareImage(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${filename}.png`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 8a391c46a3e..dd39dc9678b 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -1163,3 +1163,45 @@ img.agent-media-ring { panel, hanging off the right edge. These re-run the card's own mobile rules against the container instead, at the same 500px the card switches on, so a panel dragged wide gets the side-by-side layout back. */ + +/* Shutter feedback on the snapshot button: a highlight crossing the face once, + left to right, so the press reads as a capture rather than a submit. */ +@keyframes snapshot-shutter-sweep { + 0% { + opacity: 0; + transform: translateX(-120%) skewX(-18deg); + } + + 22% { + opacity: 1; + } + + 100% { + opacity: 0; + transform: translateX(220%) skewX(-18deg); + } +} + +.snapshot-shutter-sweep::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 60%; + pointer-events: none; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.85) 50%, + transparent 100% + ); + animation: snapshot-shutter-sweep 380ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@media (prefers-reduced-motion: reduce) { + .snapshot-shutter-sweep::after { + animation: none; + opacity: 0; + } +} diff --git a/packages/webapp/public/sounds/shutter.mp3 b/packages/webapp/public/sounds/shutter.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f49b95f152c6d13f7a411f01abb94bab8b734be3 GIT binary patch literal 45824 zcmeI&Yfw{X8o=?B1Ofs=i#RjWV=XJbF^m-ov%JCpQbWN_v@^UOKF z_dn;;@%_k=Mtt~j2;Sutr2ea`{^dGworAr$d8+@rSpEBnyT2DZ?jYOy6ZVee!_jeK zwtB#coUMz07*7y7|MqMB#YRF1Mi&30^fd98)Rn@P8iFEPUmIhbK;6k!FQ8CujNV-M zkDpm%X&HTGyqnJ5C<(*BGE*4FpyZ562ufG!d8_aRIpc1zuG|%wlM7;=8=0&X#jpma zI7y#=^=?vr=pZ@#T3#}|er8+A{Q6l=#nAN4Sx!<(>h5oX3jW?luGu#DU2ku1-(r=e zOMj~&$t*RR-Wsss2`yc6EP)*md#p6-ZW}{M%9k+p?j6^qpBSg<*5qdp`WcLLy*?#l z=AGT>-%fVwQC?5#tbb~-y?U_Ic>Z>_iJZ?gyTmY0NFzI^+$y$Sp$qP1F0&=&2?CkG zD(BAFuhVm>0;`kl<$ci;T#xc*&)iX|wSK|Ct2xV;Pq{%(w8qWp|6D~^wd8%jNvg<| zh*wkc;;G?Z1|{k!b8MvDw0ufQFrE2~V&MJrl9;U5t&{?DN~foX;cJL!1CgxG;)TP$z5~(#u))g{>Jy& zyv(F|#)+vn{x736a$fwH{M3Wh^MEc&t6xRXZT2#on-~oQb z*U7ir2VVR*5<0QwNV7;3s(y?EMMXDLL?YR5GGS#uxtOf^onQ5}qajTwDiobN>l?Q* z@_bpOmzS4+Xry$1pu)E%QravOM!HuynZ%Yp+iH9!FwAc0@(p^C9rx|&?E^XzgNS5m zJC$&7HaSsYd@=6u9_ADGDyqbMi)F*3665shy5wTc!i#pE51G2kDJ4PSirPaOEqq9J zZa(Y94rce|K2l{QBIgwIGBrNA{jWXYKMD#JYYx4Mm!&>7+9Pw$;dMKfH~4*bW-Y5H zsMRUYQ{j9qsQi0nd5vs?pY?KT)A{bus`ja?REB7yc-DcSd}LK)+j#FzO>^$WHbGbZkk*~o5hrGHL z*NW21J0C@q3T<^=STW9&TDOiV)}rvvO|OO@=~K2PwOuWF>&-fp%2FNw>P29Y^uV`9 zKC5q14$FEY?56(wtM@(5x8@a=3Ddobx0jFYZ%y-Y zlw9@-=$nxqdQmO(|2X~Pk$rQD@c^aNY^5Z_ylz1yL32`m*6?70>RrC;Nt0C~S!*@< zt+(D&@9jm4a@%>gO&xf>w=!3(sO>9^5eFsYvaMsqDG&YarrVAbgpIu~znk6H!>eo? zp6@+(wEjbKO4HRjZyI8oo~Jve*pBFJ(XpneUuhNU_nP|M#*?Vufc^PN-4k<#$9@v$ z_h&6hOw9T?jrV!u#JI12A{ir|55BM~uZ8U4J!o!TS4Y(A)9l$-mo=t^R+fo(3&`27 z-71e8T^8iE;6l#u`(K57=gYqM@XB?xcX6?Is)3(Bm%xYQHklD7Qv?yGZcLvb7OFe@ z-{kNHf_K}W>%6q!X(2f+I7H2Q1V~P^&PxmK7LwC~L)5HCfaEmmytLqMAvrBLM9q2x zNKUiPOAGE6lGB1i)T~E<b<6^g9A;2hhZ2C~P(pAWvj8NA z85Gu`1Ryz-5M0MB0Lftng>@(aNDd_g*D(t~a+pD39ZCQ`mqQ7`b-V)blEdo>+mHf~ z9FhnI@Crb3cs*eoQUH=e62Smo0Z0z7Cu~CsKypYT7{Dt4$>H^cZAbw~4oL(9cm*Ii zyq>TPDFDeKiC_S)03?Uk6Sg4*AUPxv4B!=jH^cZAbw~4oL(9cm*Ii Zyq>TPDFDeKiC_S)03?Uk6Sg4*{tKU|N9h0n literal 0 HcmV?d00001 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7126de99fe4..41be8eddbbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.22.5 version: 3.22.5 + '@zumer/snapdom': + specifier: ^2.23.1 + version: 2.24.10 border-beam: specifier: 1.3.0 version: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1124,7 +1127,7 @@ importers: dependencies: '@dailydotdev/world-kit': specifier: 0.1.1 - version: link:../world-kit + version: 0.1.1 packages/world-kit: {} @@ -1900,6 +1903,9 @@ packages: peerDependencies: postcss-selector-parser: ^7.0.0 + '@dailydotdev/world-kit@0.1.1': + resolution: {integrity: sha512-t5pzFaCP5vbh7rjAb+lZ4L/wwSAwEVNFvHEfiL42nHBa/lOuFoMBQPTldEKuBW1mQlSAl3q4bh0ZQezpHhn6cA==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -4834,6 +4840,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zumer/snapdom@2.24.10': + resolution: {integrity: sha512-yK+5HvcP96aZCG8dcOuJDsOD1TACDeSTI0wlsmQkMeeGaM/JVHdBQZPS4h0Uae6bY/zx+WQCJtOKnrbB6D7NgQ==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -11390,6 +11399,8 @@ snapshots: dependencies: postcss-selector-parser: 7.0.0 + '@dailydotdev/world-kit@0.1.1': {} + '@discoveryjs/json-ext@0.5.7': {} '@dnd-kit/accessibility@3.1.1(react@18.3.1)': @@ -14226,6 +14237,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zumer/snapdom@2.24.10': {} + abab@2.0.6: {} accepts@1.3.8: From a9805fd2a34999b4106e530d92b50b9c203ea575 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:37:06 +0300 Subject: [PATCH 2/9] feat(profile): snapshot the header, its widgets and achievements Five placements: the header action row beside edit, the Reading Overview, Badges & Awards and Achievements widget headers, and each achievement card on hover or keyboard focus. The achievement card's control sits out of flow. In flow it took 28px from the middle column and pushed long names into an ellipsis to reserve room for a button that is invisible until hover. It is positioned from a wrapper element because SnapshotButton sets `relative` on itself, which beats an `absolute` passed through className. Co-Authored-By: Claude Opus 5 --- .../src/components/profile/ProfileHeader.tsx | 19 ++++++++++-- .../ProfileWidgets/AchievementsWidget.tsx | 23 +++++++++----- .../ProfileWidgets/BadgesAndAwards.tsx | 31 ++++++++++++------- .../ProfileWidgets/ReadingOverview.tsx | 31 ++++++++++++------- .../achievements/AchievementCard.tsx | 20 ++++++++++-- 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 3061b34c3bf..35588752c53 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; import { Image } from '../image/Image'; @@ -14,7 +14,7 @@ import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; import JoinedDate from './JoinedDate'; import { Separator } from '../cards/common/common'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { webappUrl } from '../../lib/constants'; import Link from '../utilities/Link'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -24,6 +24,7 @@ import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; +import { SnapshotButton } from '../imageShare/SnapshotButton'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -67,9 +68,13 @@ const ProfileHeader = ({ const { name, username, bio, image, cover, isPlus } = user; const { user: loggedUser } = useAuthContext(); const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; + const headerRef = useRef(null); return ( -
+
Cover @@ -100,6 +105,14 @@ const ProfileHeader = ({ aria-label="Edit profile" /> + {actions}
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index 2e246ff8c46..4bdd131a459 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import classNames from 'classnames'; import Link from '../../../../components/utilities/Link'; import { ActivityContainer } from '../../../../components/profile/ActivitySection'; @@ -21,6 +21,7 @@ import { import { RaritySparkles } from '../achievements/RaritySparkles'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { AchievementCard } from '../achievements/AchievementCard'; +import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; interface AchievementsWidgetProps { user: PublicProfile; @@ -134,9 +135,10 @@ export function AchievementsWidget({ user, }: AchievementsWidgetProps): ReactElement { const { unlockedCount, totalCount } = useProfileAchievements(user); + const widgetRef = useRef(null); return ( - +
Achievements - - - {unlockedCount}/{totalCount} - - +
+ + + {unlockedCount}/{totalCount} + + + +
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index 6d800232861..bf3b83a8114 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { ActivityContainer } from '../../../../components/profile/ActivitySection'; import { topReaderBadgeDocs } from '../../../../lib/constants'; @@ -24,12 +24,14 @@ import { BadgesAndAwardsSkeleton, } from './BadgesAndAwardsComponents'; import { anchorDefaultRel } from '../../../../lib/strings'; +import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; export const BadgesAndAwards = ({ user, }: { user: PublicProfile; }): ReactElement | null => { + const widgetRef = useRef(null); const { data: topReaders, isPending: isTopReaderLoading } = useTopReader({ user, limit: 5, @@ -62,16 +64,23 @@ export const BadgesAndAwards = ({ awards?.reduce((sum, award) => sum + (award?.count || 0), 0) ?? 0; return ( - - - Badges & Awards - + +
+ + Badges & Awards + + +
value.reads; @@ -66,6 +67,7 @@ export function ReadingOverview({ mostReadTags, isLoading = false, }: ReadingOverviewProps): ReactElement { + const widgetRef = useRef(null); const totalReads = useMemo(() => { if (!readHistory?.length) { return 0; @@ -81,16 +83,23 @@ export function ReadingOverview({ } return ( - - - Reading Overview - + +
+ + Reading Overview + + +
(null); const { achievement, progress, unlockedAt } = userAchievement; const targetCount = getTargetCount(achievement); const isUnlocked = unlockedAt !== null; @@ -64,8 +66,9 @@ export function AchievementCard({ : `${Math.round(achievement.rarity ?? 0)}%`; return (
-
+
+ {/* SnapshotButton sets `relative` on itself, which beats an + `absolute` passed in, so the wrapper carries the positioning. */} + + + Date: Wed, 2 Sep 2026 17:41:53 +0300 Subject: [PATCH 3/9] feat(profile): copy link in the header, and lead the DevCard with share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header gains a copy-link control beside snapshot, matched to the buttons already there at Medium Float: sharing a profile is for getting followed, and an image cannot be followed. It reuses the existing ShareProfile event, so the header stops being a blind spot beside the ⋯ menu's Share. The DevCard flips its default from private save to public post. Download keeps its place at Float; Share leads at Primary, opening the native sheet on mobile and copying the link on desktop, under the ShareDevcard event that already existed and had no caller. Co-Authored-By: Claude Opus 5 --- .../src/components/profile/ProfileHeader.tsx | 28 ++++++++++- .../Customization/DevCard/DevCardStep2.tsx | 46 ++++++++++++++----- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 35588752c53..ea318b689a1 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -8,7 +8,7 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon } from '../icons'; +import { DevPlusIcon, EditIcon, LinkIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; @@ -25,6 +25,11 @@ import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; import { SnapshotButton } from '../imageShare/SnapshotButton'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useCopyLink } from '../../hooks/useCopy'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -69,6 +74,18 @@ const ProfileHeader = ({ const { user: loggedUser } = useAuthContext(); const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; const headerRef = useRef(null); + const { logEvent } = useLogContext(); + const [isCopying, copyLink] = useCopyLink(() => user.permalink); + + const onCopyLink = () => { + copyLink(); + logEvent({ + event_name: LogEvent.ShareProfile, + target_type: TargetType.ProfilePage, + target_id: user.id, + extra: JSON.stringify({ provider: ShareProvider.CopyLink }), + }); + }; return (
+ +
diff --git a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx index f386d178e2e..080ca472072 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx @@ -15,6 +15,7 @@ import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useDevCard } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useCopyLink } from '@dailydotdev/shared/src/hooks/useCopy'; +import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; import { downloadUrl } from '@dailydotdev/shared/src/lib/blob'; import { generateQueryKey, @@ -32,8 +33,10 @@ import { import { RadioItem } from '@dailydotdev/shared/src/components/fields/RadioItem'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { + DownloadIcon, GitHubIcon, OpenLinkIcon, + ShareIcon, TwitterIcon, } from '@dailydotdev/shared/src/components/icons'; import { DevCardFetchWrapper } from '@dailydotdev/shared/src/components/profile/devcard/DevCardFetchWrapper'; @@ -90,6 +93,14 @@ export const DevCardStep2 = ({ [user?.name, user?.username, devCardSrc, type], ); const [copyingEmbed, copyEmbed] = useCopyLink(() => embedCode); + const [sharing, onShareDevCard] = useShareOrCopyLink({ + link: user?.permalink ?? '', + text: 'Check out my #DevCard on daily.dev', + logObject: (provider) => ({ + event_name: LogEvent.ShareDevcard, + extra: JSON.stringify({ provider }), + }), + }); const [selectedTab, setSelectedTab] = useState(0); const { mutateAsync: onDownloadUrl, isPending: downloading } = useMutation({ mutationFn: downloadUrl, @@ -230,18 +241,29 @@ export const DevCardStep2 = ({
{!isNullOrUndefined(devcard) && ( - +
+ + +
)} From 3e9e5310d155ae0d52e8d3c8af968315644242db Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:41:53 +0300 Subject: [PATCH 4/9] docs(snapshot): add the profile surface page to Storybook The design page behind these controls: where each one sits, on desktop and mobile, against the alternatives that were rejected. Mockup-to-eng-pass: 1 Co-Authored-By: Claude Opus 5 --- .../features/snapshot/surfaceChrome.tsx | 230 +++++++++ .../snapshot/surfaces/Profile.stories.tsx | 435 ++++++++++++++++++ 2 files changed, 665 insertions(+) create mode 100644 packages/storybook/stories/features/snapshot/surfaceChrome.tsx create mode 100644 packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 00000000000..3442fc3724c --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + LinkIcon, + ShareIcon, + SnapshotIcon, +} 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 */ + +const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const Note = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +/* ---------------------------------------------------------------- controls */ + +type LeadAction = 'Link' | 'Share to' | 'Snapshot'; + +const ICONS: Record = { + Link: , + 'Share to': , + Snapshot: , +}; + +const LABELS: Record = { + Link: 'Copy link', + 'Share to': 'Share', + Snapshot: 'Snapshot', +}; + +/** + * Inert on purpose: this page compares where a control sits inside a real + * screen. The working buttons and live capture are on the profile itself. + */ +export const Control = ({ + action, + className, + label, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, +}: { + action: LeadAction; + className?: string; + label?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +}) => ( + +); + +/* ---------------------------------------------------------- page furniture */ + +/** + * A real context menu. Every production menu in the product leads with a + * share item — "Share via" on posts and squads, "Share" on profiles and + * tags — and none of them offers "Copy link" directly, so the items are + * passed in rather than invented. + */ +export const OverflowMenu = ({ + items, + highlight, + className, +}: { + items: string[]; + /** The share item, whatever this surface actually calls it. */ + highlight?: string; + className?: string; +}) => ( +
+ {items.map((item) => { + const isShare = item === highlight; + + return ( + + {isShare && } + {item} + + ); + })} +
+); + +export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +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/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx new file mode 100644 index 00000000000..f45c55ac43c --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx @@ -0,0 +1,435 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownloadIcon, + EditIcon, + MedalBadgeIcon, + MenuIcon, + ReputationIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + OverflowMenu, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'today' | 'menu' | 'link' | 'lead'; + +/* ------------------------------------------------------------------ header */ + +const ProfileScreen = ({ + device, + spot, + visitor, +}: { + device: DeviceName; + spot: Spot; + visitor?: boolean; +}) => ( + +
+
+ + +
+
+
+ + + Tomer Redlich + + +
+

+ Building the feed developers actually read. +

+ Tel Aviv + + @tomer · Joined Jan 4. 2021 + + + {visitor && ( +
+ + +
+ )} + +
+ + + 1.2K Reputation + + + 3.4K Upvotes + + + 842 Followers + + + 61 Following + +
+
+
+
+ +); + +/* ----------------------------------------------------------------- widgets */ + +const SummaryCard = ({ count, label }: { count: string; label: string }) => ( +
+ {count} + {label} +
+); + +const WidgetHeader = ({ + title, + icon, + trailing, + snapshot, +}: { + title: string; + icon?: React.ReactNode; + trailing?: React.ReactNode; + snapshot: boolean; +}) => ( +
+

+ {icon} + {title} +

+
+ {trailing} + {snapshot && } +
+
+); + +const WidgetsScreen = ({ + device, + snapshot, +}: { + device: DeviceName; + snapshot: boolean; +}) => ( + +
+
+ + Learn more +
+ + +
+

+ Top tags by reading days +

+
+ {[ + ['#typescript', 82], + ['#react', 64], + ['#webdev', 41], + ['#css', 28], + ].map(([tag, pct]) => ( +
+ + + {tag} + +
+ ))} +
+

+ Posts read in the last months (3.4K) +

+
+ {Array.from({ length: 60 }, (_, i) => { + const level = Math.max( + 0, + Math.min(3, Math.round(2 + Math.sin(i / 4) * 1.4)), + ); + const tone = [ + 'bg-surface-float', + 'bg-overlay-float-cabbage', + 'bg-accent-cabbage-subtler', + 'bg-accent-cabbage-default', + ][level]; + + return ( + // eslint-disable-next-line react/no-array-index-key + + ); + })} +
+
+ +
+ + Learn more +
+ + +
+
+ {['#typescript', '#react'].map((tag) => ( + + 🥇 Top reader in {tag} + + ))} +
+
+ +
+ } + snapshot={snapshot} + title="Achievements" + trailing={12/40} + /> +
+ {["Can't spend it all", 'Big byte energy'].map((name) => ( +
+ +
+ + {name} + + + Unlocked 12 Aug 2026 + +
+ + 120 + +
+ ))} +
+
+ {device === 'Mobile' && mobile} +
+
+); + +/* ---------------------------------------------------------------- devcard */ + +const DevCardScreen = ({ lead }: { lead: boolean }) => ( + +
+ + Your DevCard is ready + +
+
+ + +
+
+ +); + +/* -------------------------------------------------------------------- page */ + +const Profile = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Profile', + component: Profile, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; From 6e7ab3e7e5cffbbbe4b2f259d150d2eb9a20bc9a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 12:56:30 +0300 Subject: [PATCH 5/9] feat(profile): confirm the copy with an arrow, and show only what ships The header's copy link relied on the toast alone. It now swaps to the upvote button's filled avocado arrow and spins through the same curve, so the gesture that means "that worked" looks the same in both places. The Storybook page drew each of the three surfaces twice, before and after, plus a louder copy-link treatment we did not take. Only the shipped state remains, and the props that switched between states go with the halves they served. Co-Authored-By: Claude Opus 5 --- .../src/components/profile/ProfileHeader.tsx | 13 +- packages/shared/tailwind.config.ts | 9 ++ .../features/snapshot/surfaceChrome.tsx | 41 ----- .../snapshot/surfaces/Profile.stories.tsx | 149 +++--------------- 4 files changed, 40 insertions(+), 172 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index ea318b689a1..1c764000d3b 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -8,7 +8,7 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon, LinkIcon } from '../icons'; +import { DevPlusIcon, EditIcon, LinkIcon, UpvoteIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; @@ -133,7 +133,16 @@ const ProfileHeader = ({
@@ -90,34 +67,6 @@ const ProfileScreen = ({ @tomer · Joined Jan 4. 2021 - {visitor && ( -
- - -
- )} -
@@ -152,12 +101,10 @@ const WidgetHeader = ({ title, icon, trailing, - snapshot, }: { title: string; icon?: React.ReactNode; trailing?: React.ReactNode; - snapshot: boolean; }) => (

@@ -166,22 +113,16 @@ const WidgetHeader = ({

{trailing} - {snapshot && } +
); -const WidgetsScreen = ({ - device, - snapshot, -}: { - device: DeviceName; - snapshot: boolean; -}) => ( +const WidgetsScreen = ({ device }: { device: DeviceName }) => (
- + Learn more
@@ -236,7 +177,7 @@ const WidgetsScreen = ({
- + Learn more
@@ -257,7 +198,6 @@ const WidgetsScreen = ({
} - snapshot={snapshot} title="Achievements" trailing={12/40} /> @@ -290,7 +230,7 @@ const WidgetsScreen = ({ /* ---------------------------------------------------------------- devcard */ -const DevCardScreen = ({ lead }: { lead: boolean }) => ( +const DevCardScreen = () => (
@@ -301,14 +241,14 @@ const DevCardScreen = ({ lead }: { lead: boolean }) => (
@@ -326,46 +266,16 @@ const Profile = () => ( - - - - - - - - - - - - - - - - - - - - + + @@ -373,26 +283,16 @@ const Profile = () => ( - - - - - - - - + + @@ -402,22 +302,13 @@ const Profile = () => ( title="The DevCard" verdict="Share to leads, and now ships. The card is already an image; the job is getting it posted rather than saved." > - - - - - - + From e93a91fd9b76637d0ce312e2837497601db4db8a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 14:35:50 +0300 Subject: [PATCH 6/9] fix(profile): shrink the widget snapshot buttons to XSmall The three widget headers took Button's default Small, which sat heavier than the Learn more and 12/40 links beside them. XSmall matches the achievement card's control and the weight of the text it shares the row with. The header button keeps Medium, where it is matched to edit. Co-Authored-By: Claude Opus 5 --- .../profile/components/ProfileWidgets/AchievementsWidget.tsx | 2 ++ .../profile/components/ProfileWidgets/BadgesAndAwards.tsx | 2 ++ .../profile/components/ProfileWidgets/ReadingOverview.tsx | 2 ++ .../stories/features/snapshot/surfaces/Profile.stories.tsx | 4 ++-- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index 4bdd131a459..f0e045f7204 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -22,6 +22,7 @@ import { RaritySparkles } from '../achievements/RaritySparkles'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { AchievementCard } from '../achievements/AchievementCard'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; interface AchievementsWidgetProps { user: PublicProfile; @@ -159,6 +160,7 @@ export function AchievementsWidget({
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index bf3b83a8114..7a67a1fe074 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx @@ -25,6 +25,7 @@ import { } from './BadgesAndAwardsComponents'; import { anchorDefaultRel } from '../../../../lib/strings'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; export const BadgesAndAwards = ({ user, @@ -78,6 +79,7 @@ export const BadgesAndAwards = ({
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx index f6eea20b2d4..5b75e7402cb 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx @@ -24,6 +24,7 @@ import { import { anchorDefaultRel, pluralize } from '../../../../lib/strings'; import { largeNumberFormat } from '../../../../lib'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; // Utility functions const readHistoryToValue = (value: UserReadHistory): number => value.reads; @@ -97,6 +98,7 @@ export function ReadingOverview({
diff --git a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx index cdbc60bbb30..7ef5e069503 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx @@ -113,7 +113,7 @@ const WidgetHeader = ({
{trailing} - +
); @@ -287,7 +287,7 @@ const Profile = () => ( > From f4bc21f9396bc08a395cd88069dd682be0e1b72b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 14:44:04 +0300 Subject: [PATCH 7/9] docs(snapshot): drop the profile surface page from Storybook The design page has served its purpose: the controls it compared are shipped and reviewable on the profile itself. Removing it takes the surface chrome with it, since nothing else imported either file. Co-Authored-By: Claude Opus 5 --- .../features/snapshot/surfaceChrome.tsx | 189 ---------- .../snapshot/surfaces/Profile.stories.tsx | 326 ------------------ 2 files changed, 515 deletions(-) delete mode 100644 packages/storybook/stories/features/snapshot/surfaceChrome.tsx delete mode 100644 packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx deleted file mode 100644 index 1ab3473c53a..00000000000 --- a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - LinkIcon, - ShareIcon, - SnapshotIcon, -} 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 */ - -const H1 = ({ children }: { children: React.ReactNode }) => ( -

{children}

-); - -const P = ({ children }: { children: React.ReactNode }) => ( -

{children}

-); - -const Note = ({ children }: { children: React.ReactNode }) => ( -

- {children} -

-); - -/* ---------------------------------------------------------------- controls */ - -type LeadAction = 'Link' | 'Share to' | 'Snapshot'; - -const ICONS: Record = { - Link: , - 'Share to': , - Snapshot: , -}; - -const LABELS: Record = { - Link: 'Copy link', - 'Share to': 'Share', - Snapshot: 'Snapshot', -}; - -/** - * Inert on purpose: this page compares where a control sits inside a real - * screen. The working buttons and live capture are on the profile itself. - */ -export const Control = ({ - action, - className, - label, - size = ButtonSize.Small, - variant = ButtonVariant.Tertiary, -}: { - action: LeadAction; - className?: string; - label?: boolean; - size?: ButtonSize; - variant?: ButtonVariant; -}) => ( - -); - -/* ---------------------------------------------------------- page furniture */ - -export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; - -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/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx deleted file mode 100644 index 7ef5e069503..00000000000 --- a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - DownloadIcon, - EditIcon, - MedalBadgeIcon, - ReputationIcon, -} from '@dailydotdev/shared/src/components/icons'; -import type { DeviceName } from '../surfaceChrome'; -import { - AVATAR, - Category, - Control, - Device, - Rail, - SurfacePage, - Variant, -} from '../surfaceChrome'; - -/* ------------------------------------------------------------------ header */ - -const ProfileScreen = ({ device }: { device: DeviceName }) => ( - -
-
- - -
-
-
- - - Tomer Redlich - - -
-

- Building the feed developers actually read. -

- Tel Aviv - - @tomer · Joined Jan 4. 2021 - - -
- - - 1.2K Reputation - - - 3.4K Upvotes - - - 842 Followers - - - 61 Following - -
-
-
-
- -); - -/* ----------------------------------------------------------------- widgets */ - -const SummaryCard = ({ count, label }: { count: string; label: string }) => ( -
- {count} - {label} -
-); - -const WidgetHeader = ({ - title, - icon, - trailing, -}: { - title: string; - icon?: React.ReactNode; - trailing?: React.ReactNode; -}) => ( -
-

- {icon} - {title} -

-
- {trailing} - -
-
-); - -const WidgetsScreen = ({ device }: { device: DeviceName }) => ( - -
-
- - Learn more -
- - -
-

- Top tags by reading days -

-
- {[ - ['#typescript', 82], - ['#react', 64], - ['#webdev', 41], - ['#css', 28], - ].map(([tag, pct]) => ( -
- - - {tag} - -
- ))} -
-

- Posts read in the last months (3.4K) -

-
- {Array.from({ length: 60 }, (_, i) => { - const level = Math.max( - 0, - Math.min(3, Math.round(2 + Math.sin(i / 4) * 1.4)), - ); - const tone = [ - 'bg-surface-float', - 'bg-overlay-float-cabbage', - 'bg-accent-cabbage-subtler', - 'bg-accent-cabbage-default', - ][level]; - - return ( - // eslint-disable-next-line react/no-array-index-key - - ); - })} -
-
- -
- - Learn more -
- - -
-
- {['#typescript', '#react'].map((tag) => ( - - 🥇 Top reader in {tag} - - ))} -
-
- -
- } - title="Achievements" - trailing={12/40} - /> -
- {["Can't spend it all", 'Big byte energy'].map((name) => ( -
- -
- - {name} - - - Unlocked 12 Aug 2026 - -
- - 120 - -
- ))} -
-
- {device === 'Mobile' && mobile} -
-
-); - -/* ---------------------------------------------------------------- devcard */ - -const DevCardScreen = () => ( - -
- - Your DevCard is ready - -
-
- - -
-
- -); - -/* -------------------------------------------------------------------- page */ - -const Profile = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Profile', - component: Profile, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; From aa8f9c09a443a4dba4ff97fdb22f9a45d6e4ff23 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 16:11:44 +0300 Subject: [PATCH 8/9] fix(share): tell the user when a copy did not happen A refused clipboard write rejected out of useCopyLink, so the caller got no toast, no copied state and an unhandled rejection: the button read as dead. It now reports the failure, and the copied state is set only after the write lands, so a confirmation cannot claim a copy that did not happen. The missing-link path stops reporting a copy for the same reason. Two strict-mode errors in the file surfaced once it entered the changed set. An optional getLink was invoked unconditionally, and useCopyText passed a possibly undefined value to writeText, which would have put the string "undefined" on the clipboard. Co-Authored-By: Claude Opus 5 --- packages/shared/src/hooks/useCopy.spec.ts | 67 +++++++++++++++++++++++ packages/shared/src/hooks/useCopy.ts | 63 +++++++++++++-------- 2 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 packages/shared/src/hooks/useCopy.spec.ts diff --git a/packages/shared/src/hooks/useCopy.spec.ts b/packages/shared/src/hooks/useCopy.spec.ts new file mode 100644 index 00000000000..905a7892053 --- /dev/null +++ b/packages/shared/src/hooks/useCopy.spec.ts @@ -0,0 +1,67 @@ +import { act, renderHook } from '@testing-library/react'; +import { useCopyLink } from './useCopy'; + +const mockDisplayToast = jest.fn(); +const mockWriteText = jest.fn(); + +jest.mock('./useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +jest.mock('./utils/useGetShortUrl', () => ({ + useGetShortUrl: () => ({ getShortUrl: jest.fn() }), +})); + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText: mockWriteText } }); +}); + +it('copies the link and reports the copied state', async () => { + mockWriteText.mockResolvedValue(undefined); + const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev')); + + await act(async () => { + await result.current[1](); + }); + + expect(mockWriteText).toHaveBeenCalledWith('https://daily.dev'); + expect(mockDisplayToast).toHaveBeenCalledWith( + '✅ Copied link to clipboard', + {}, + ); + expect(result.current[0]).toBe(true); +}); + +it('says so when the clipboard refuses the write', async () => { + mockWriteText.mockRejectedValue( + new DOMException('Document is not focused.', 'NotAllowedError'), + ); + const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev')); + + await act(async () => { + await result.current[1](); + }); + + expect(mockDisplayToast).toHaveBeenCalledWith( + '❌ Could not copy, please try again', + {}, + ); + // Nothing was copied, so the caller must not render a copied confirmation. + expect(result.current[0]).toBe(false); +}); + +it('does not report a copy when there is no link', async () => { + const { result } = renderHook(() => useCopyLink(() => '')); + + await act(async () => { + await result.current[1](); + }); + + expect(mockWriteText).not.toHaveBeenCalled(); + expect(mockDisplayToast).toHaveBeenCalledWith( + '❌ Could not copy, link is missing', + {}, + ); + expect(result.current[0]).toBe(false); +}); diff --git a/packages/shared/src/hooks/useCopy.ts b/packages/shared/src/hooks/useCopy.ts index ac772be919b..cc8f6fbe787 100644 --- a/packages/shared/src/hooks/useCopy.ts +++ b/packages/shared/src/hooks/useCopy.ts @@ -14,6 +14,8 @@ type CopyNotifyFunctionProps = NotifyOptionalProps & { const defaultMessage = '✅ Copied to clipboard'; const defaultLinkMessage = '✅ Copied link to clipboard'; const noLinkErrorMessage = '❌ Could not copy, link is missing'; +const copyFailedMessage = '❌ Could not copy, please try again'; +const noTextErrorMessage = '❌ Could not copy, there is nothing to copy'; export type CopyNotifyFunction = | ((props?: CopyNotifyFunctionProps) => void) @@ -28,33 +30,42 @@ export function useCopyLink( const { getShortUrl } = useGetShortUrl(); const copy: CopyNotifyFunction = async (props = {}) => { - const link = props.link || getLink(); + const link = props.link || getLink?.(); const shortenLink = props.shorten || shorten; - if (link) { - // write the link to clipboard + if (!link) { + displayToast(noLinkErrorMessage, props); + + return; + } + + try { await navigator.clipboard.writeText(link); + } catch { + // A refused write used to reject out of here, leaving the caller with no + // toast and no copied state, so the button read as dead. + displayToast(copyFailedMessage, props); - // try with a shortened link as well, if requested - if (shortenLink) { - try { - const clipBoardItem = new ClipboardItem({ - 'text/plain': getShortUrl(link).then((shortenedLink) => { - return new Blob([shortenedLink], { type: 'text/plain' }); - }), - }); - await navigator.clipboard.write([clipBoardItem]); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('Error copying to clipboard', e); - } - } + return; + } - if (!props.disableToast) { - displayToast(props.message || defaultLinkMessage, props); + // try with a shortened link as well, if requested + if (shortenLink) { + try { + const clipBoardItem = new ClipboardItem({ + 'text/plain': getShortUrl(link).then((shortenedLink) => { + return new Blob([shortenedLink], { type: 'text/plain' }); + }), + }); + await navigator.clipboard.write([clipBoardItem]); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error copying to clipboard', e); } - } else { - displayToast(noLinkErrorMessage, props); + } + + if (!props.disableToast) { + displayToast(props.message || defaultLinkMessage, props); } setCopying(true); @@ -71,7 +82,15 @@ export function useCopyText(text?: string): [boolean, CopyNotifyFunction] { const { displayToast } = useToastNotification(); const copy: CopyNotifyFunction = async (props = {}) => { - await navigator.clipboard.writeText(props.textToCopy || text); + const textToCopy = props.textToCopy || text; + + if (!textToCopy) { + displayToast(noTextErrorMessage, props); + + return; + } + + await navigator.clipboard.writeText(textToCopy); if (!props.disableToast) { displayToast(props.message || defaultMessage, props); From 56723b63d6ce8c973fe565d6ec8df7eb8695d5f7 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 17:52:19 +0300 Subject: [PATCH 9/9] feat(profile): confirm the share widget's copy with the same green arrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's copy link already swapped to the upvote arrow, but the profile page's other copy control — the Public profile & URL row — only filled its copy icon, so the same gesture confirmed two different ways on one page. Both now render CopyConfirmIcon, which carries the arrow, the avocado and the spin in one place instead of each caller repeating the class list. Co-Authored-By: Claude Opus 5 --- .../components/buttons/CopyConfirmIcon.tsx | 26 +++++++++++++++++++ .../src/components/profile/ProfileHeader.tsx | 14 +++------- .../components/ProfileWidgets/Share.tsx | 3 ++- 3 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/components/buttons/CopyConfirmIcon.tsx diff --git a/packages/shared/src/components/buttons/CopyConfirmIcon.tsx b/packages/shared/src/components/buttons/CopyConfirmIcon.tsx new file mode 100644 index 00000000000..cfbd574bfa4 --- /dev/null +++ b/packages/shared/src/components/buttons/CopyConfirmIcon.tsx @@ -0,0 +1,26 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { UpvoteIcon } from '../icons'; +import type { IconProps } from '../Icon'; + +/** + * The confirmation half of a copy control: the same filled arrow and spin the + * upvote button uses, so the gesture that means "that worked" looks the same + * everywhere. Swap it in for the resting icon while the copy is confirmed. + */ +export function CopyConfirmIcon({ + className, + ...props +}: IconProps): ReactElement { + return ( + + ); +} diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 1c764000d3b..685ff396cb4 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -8,13 +8,14 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon, LinkIcon, UpvoteIcon } from '../icons'; +import { DevPlusIcon, EditIcon, LinkIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; import JoinedDate from './JoinedDate'; import { Separator } from '../cards/common/common'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyConfirmIcon } from '../buttons/CopyConfirmIcon'; import { webappUrl } from '../../lib/constants'; import Link from '../utilities/Link'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -133,16 +134,7 @@ const ProfileHeader = ({