Expand social networks (#308)

* Create utility for managing env var-based key lists

* Add expanded social content

* Finalize content for social networks additions

* Add social network config to README
This commit is contained in:
Sam Becker 2025-09-10 23:22:26 -05:00 committed by GitHub
parent 25a8a473ab
commit 7c9ce0d26c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 566 additions and 587 deletions

View File

@ -170,7 +170,6 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
#### Grid
@ -185,6 +184,15 @@ Application behavior can be changed by configuring the following environment var
#### Settings
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)
- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)
- `NEXT_PUBLIC_SOCIAL_NETWORKS`
- Comma-separated list of social networks to show in share modal
- Accepted values:
- `x` (default)
- `threads`
- `facebook`
- `linkedin`
- `all`
- `none`
- `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml`
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)

View File

@ -1,14 +1,14 @@
import {
DEFAULT_CATEGORY_KEYS,
getOrderedCategoriesFromString,
parseOrderedCategoriesFromString,
} from '@/category';
describe('set', () => {
it('parses from string', () => {
expect(getOrderedCategoriesFromString())
expect(parseOrderedCategoriesFromString())
.toStrictEqual(DEFAULT_CATEGORY_KEYS);
expect(getOrderedCategoriesFromString(
expect(parseOrderedCategoriesFromString(
'cameras,recipes,tags,films,focal-lengths,lenses',
)).toStrictEqual([
'cameras',
@ -19,7 +19,7 @@ describe('set', () => {
'lenses',
]);
expect(getOrderedCategoriesFromString(
expect(parseOrderedCategoriesFromString(
'cameras, recipes, tags, films',
)).toStrictEqual([
'cameras',
@ -28,7 +28,7 @@ describe('set', () => {
'films',
]);
expect(getOrderedCategoriesFromString(
expect(parseOrderedCategoriesFromString(
'cameras',
)).toStrictEqual([
'cameras',

View File

@ -9,10 +9,10 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.25",
"@ai-sdk/rsc": "^1.0.34",
"@aws-sdk/client-s3": "3.883.0",
"@aws-sdk/s3-request-presigner": "3.883.0",
"@ai-sdk/openai": "^2.0.27",
"@ai-sdk/rsc": "^1.0.39",
"@aws-sdk/client-s3": "3.884.0",
"@aws-sdk/s3-request-presigner": "3.884.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
@ -22,8 +22,8 @@
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^1.1.1",
"@vercel/speed-insights": "^1.2.0",
"ai": "^5.0.34",
"camelcase-keys": "^9.1.3",
"ai": "^5.0.39",
"camelcase-keys": "^10.0.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"culori": "^4.0.2",

657
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ import {
} from 'react';
import ChecklistRow from '@/components/ChecklistRow';
import ChecklistGroup from '@/components/ChecklistGroup';
import { AppConfiguration } from '@/app/config';
import { AppConfiguration, SOCIAL_KEYS } from '@/app/config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/platforms/storage';
import { testConnectionsAction } from '@/admin/actions';
@ -19,7 +19,7 @@ import SecretGenerator from '@/app/SecretGenerator';
import EnvVar from '@/components/EnvVar';
import AdminLink from '@/admin/AdminLink';
import ScoreCardContainer from '@/components/ScoreCardContainer';
import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category';
import { CATEGORY_KEYS, DEFAULT_CATEGORY_KEYS } from '@/category';
import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai';
import clsx from 'clsx/lite';
import Link from 'next/link';
@ -32,6 +32,8 @@ import {
} from '.';
import ColorDot from '@/photo/color/ColorDot';
import { Oklch } from '@/photo/color/client';
import { getOrderedKeyListStatus } from '@/utility/key';
import { DEFAULT_SOCIAL_KEYS } from '@/social';
export default function AdminAppConfigurationClient({
// Storage
@ -100,7 +102,6 @@ export default function AdminAppConfigurationClient({
showExifInfo,
showZoomControls,
showTakenAtTimeHidden,
showSocial,
showRepoLink,
// Grid
isGridHomepageEnabled,
@ -118,6 +119,8 @@ export default function AdminAppConfigurationClient({
// Settings
isGeoPrivacyEnabled,
arePublicDownloadsEnabled,
hasSocialKeys,
socialKeys,
areSiteFeedsEnabled,
isOgTextBottomAligned,
// Internal
@ -185,6 +188,23 @@ export default function AdminAppConfigurationClient({
'translate-y-[7px]',
);
const renderOrderedKeyList = (
selectedKeys: string[],
acceptedKeys: readonly string[],
) =>
<div>
{getOrderedKeyListStatus({ selectedKeys, acceptedKeys })
.map(({ label, selected }) =>
<Fragment key={label}>
{renderSubStatus(
selected ? 'checked' : 'optional',
selected
? label
: <span className="text-dim">{label}</span>,
)}
</Fragment>)}
</div>;
const renderError = ({
connection,
message,
@ -568,34 +588,13 @@ export default function AdminAppConfigurationClient({
status={hasCategoryVisibility}
optional
>
{renderOrderedKeyList(categoryVisibility, CATEGORY_KEYS)}
<div>
{categoryVisibility.map((category, index) =>
<Fragment key={category}>
{renderSubStatus(
'checked',
<>
{index + 1}
{'.'}
{category}
</>,
)}
</Fragment>)}
{getHiddenCategories(categoryVisibility)
.map(category =>
<Fragment key={category}>
{renderSubStatus(
'optional',
<span className="text-dim">
{'* '}
{category}
</span>,
)}
</Fragment>)}
</div>
Configure order and visibility of categories
(seen in grid sidebar and CMD-K results)
by storing comma-separated values
(default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}):
</div>
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
</ChecklistRow>
<ChecklistRow
@ -749,16 +748,6 @@ export default function AdminAppConfigurationClient({
taken at time from photo meta:
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])}
</ChecklistRow>
<ChecklistRow
title="Show social"
status={showSocial}
optional
>
Set environment variable to {'"1"'} to hide
{' '}
X (formerly Twitter) button from share modal:
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
@ -865,6 +854,20 @@ export default function AdminAppConfigurationClient({
public photo downloads for all visitors:
{renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])}
</ChecklistRow>
<ChecklistRow
title="Social networks"
status={hasSocialKeys}
optional
>
{renderOrderedKeyList(socialKeys, SOCIAL_KEYS)}
<div>
Configure order and visibility of social networks
(seen in share modal) by storing comma-separated values
(accepts {'"all"'} or {'"none"'},
defaults to {`"${DEFAULT_SOCIAL_KEYS.join(',')}"`})
</div>
{renderEnvVars(['NEXT_PUBLIC_SOCIAL_NETWORKS'])}
</ChecklistRow>
<ChecklistRow
title="Site feeds (JSON/RSS)"
status={areSiteFeedsEnabled}

View File

@ -2,7 +2,7 @@ import {
AI_AUTO_GENERATED_FIELDS_DEFAULT,
parseAiAutoGeneratedFieldsString,
} from '@/photo/ai';
import { getOrderedCategoriesFromString } from '@/category';
import { parseOrderedCategoriesFromString } from '@/category';
import type { StorageType } from '@/platforms/storage';
import {
makeUrlAbsolute,
@ -10,6 +10,7 @@ import {
} from '@/utility/url';
import { getNavSortControlFromString, getSortByFromString } from '@/photo/sort';
import { parseChromaCutoff, parseStartingHue } from '@/photo/color/sort';
import { parseSocialKeysFromString } from '@/social';
// HARD-CODED GLOBAL CONFIGURATION
@ -257,7 +258,7 @@ export const BLUR_ENABLED =
// CATEGORIES
export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
export const CATEGORY_VISIBILITY = parseOrderedCategoriesFromString(
process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY);
export const SHOW_RECENTS =
CATEGORY_VISIBILITY.includes('recents');
@ -314,8 +315,6 @@ export const SHOW_ZOOM_CONTROLS =
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
export const SHOW_TAKEN_AT_TIME =
process.env.NEXT_PUBLIC_HIDE_TAKEN_AT_TIME !== '1';
export const SHOW_SOCIAL =
process.env.NEXT_PUBLIC_HIDE_SOCIAL !== '1';
export const SHOW_REPO_LINK =
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
@ -354,6 +353,12 @@ export const GEO_PRIVACY_ENABLED =
process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
export const ALLOW_PUBLIC_DOWNLOADS =
process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1';
export const SOCIAL_KEYS = parseSocialKeysFromString(
// Legacy environment variable
process.env.NEXT_PUBLIC_HIDE_SOCIAL === '1'
? 'none'
: process.env.NEXT_PUBLIC_SOCIAL_NETWORKS,
);
export const SITE_FEEDS_ENABLED =
process.env.NEXT_PUBLIC_SITE_FEEDS === '1';
export const OG_TEXT_BOTTOM_ALIGNMENT =
@ -458,7 +463,6 @@ export const APP_CONFIGURATION = {
showExifInfo: SHOW_EXIF_DATA,
showZoomControls: SHOW_ZOOM_CONTROLS,
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
showSocial: SHOW_SOCIAL,
showRepoLink: SHOW_REPO_LINK,
// Grid
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
@ -479,6 +483,8 @@ export const APP_CONFIGURATION = {
// Settings
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS,
hasSocialKeys: Boolean(process.env.NEXT_PUBLIC_SOCIAL_NETWORKS),
socialKeys: SOCIAL_KEYS,
areSiteFeedsEnabled: SITE_FEEDS_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
// Internal
@ -498,7 +504,25 @@ export const APP_CONFIGURATION = {
commitUrl: VERCEL_GIT_COMMIT_URL,
};
export const IS_SITE_READY =
const ALL_LEGACY_ENV_VARS = [
'NEXT_PUBLIC_SITE_DOMAIN',
'NEXT_PUBLIC_SITE_DESCRIPTION',
'NEXT_PUBLIC_SITE_TITLE',
'NEXT_PUBLIC_SITE_ABOUT',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES',
'NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES',
'NEXT_PUBLIC_PRO_MODE',
'NEXT_PUBLIC_HIDE_SOCIAL',
'NEXT_PUBLIC_SITE_DOMAIN',
];
export const USED_LEGACY_ENV_VARS = ALL_LEGACY_ENV_VARS
.filter(variable => Boolean(process.env[variable]));
export const DOES_APP_HAVE_LEGACY_ENV_VARS =
USED_LEGACY_ENV_VARS.length > 0;
export const IS_APP_READY =
APP_CONFIGURATION.hasDatabase &&
APP_CONFIGURATION.hasStorageProvider &&
APP_CONFIGURATION.hasAuthSecret &&

View File

@ -7,8 +7,9 @@ import { FocalLengths } from '@/focal';
import { Recipes } from '@/recipe';
import { Recents } from '@/recents';
import { Years } from '@/years';
import { parseCommaSeparatedKeyString } from '@/utility/key';
const CATEGORY_KEYS = [
export const CATEGORY_KEYS = [
'recents',
'years',
'cameras',
@ -32,14 +33,20 @@ export const DEFAULT_CATEGORY_KEYS: CategoryKeys = [
'films',
];
export const parseOrderedCategoriesFromString = (
string?: string,
) =>
parseCommaSeparatedKeyString({
string,
acceptedKeys: CATEGORY_KEYS,
defaultKeys: DEFAULT_CATEGORY_KEYS,
});
export interface CategoryQueryMeta {
count: number
lastModified: Date
}
export const getHiddenCategories = (keys: CategoryKeys): CategoryKeys =>
CATEGORY_KEYS.filter(key => !keys.includes(key));
export const getHiddenDefaultCategories = (keys: CategoryKeys): CategoryKeys =>
DEFAULT_CATEGORY_KEYS.filter(key => !keys.includes(key));
@ -71,16 +78,6 @@ export interface PhotoSetAttributes {
dateRange?: PhotoDateRange
}
export const getOrderedCategoriesFromString = (
categories?: string,
): CategoryKeys =>
categories
? categories
.split(',')
.map(category => category.trim().toLocaleLowerCase() as CategoryKey)
.filter(category => CATEGORY_KEYS.includes(category))
: DEFAULT_CATEGORY_KEYS;
export const sortCategoryByCount = (
a: { count: number },
b: { count: number },

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': '৩৫মিমি সমতুল্য',
zoom: 'জুম ইন',
sharePhoto: 'ছবি শেয়ার করুন',
recipeInfo: 'রেসিপি তথ্য',
recipeCopy: 'রেসিপি কপি করুন',
download: 'মূল ফাইল ডাউনলোড করুন',
sharePhoto: 'ছবি শেয়ার করুন',
shareCopy: 'লিংক কপি করুন',
shareTo: 'শেয়ার করুন ...',
shareX: 'X এ শেয়ার করুন',
shareThreads: 'Threads এ শেয়ার করুন',
shareFacebook: 'Facebook এ শেয়ার করুন',
shareLinkedIn: 'LinkedIn এ শেয়ার করুন',
},
theme: {
theme: 'থিম',

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': '35mm Equivalent',
zoom: 'Zoom In',
sharePhoto: 'Share Photo',
recipeInfo: 'Recipe Info',
recipeCopy: 'Copy Recipe Text',
download: 'Download Original File',
sharePhoto: 'Share Photo',
shareCopy: 'Copy Link',
shareTo: 'Share ...',
shareX: 'Share on X',
shareThreads: 'Share on Threads',
shareFacebook: 'Share on Facebook',
shareLinkedIn: 'Share on LinkedIn',
},
theme: {
theme: 'Theme',

View File

@ -80,10 +80,16 @@ export const TEXT = {
tooltip: {
'35mm': '35mm Equivalent',
zoom: 'Zoom In',
sharePhoto: 'Share Photo',
recipeInfo: 'Recipe Info',
recipeCopy: 'Copy Recipe Text',
download: 'Download Original File',
sharePhoto: 'Share Photo',
shareCopy: 'Copy Link',
shareTo: 'Share ...',
shareX: 'Share on X',
shareThreads: 'Share on Threads',
shareFacebook: 'Share on Facebook',
shareLinkedIn: 'Share on LinkedIn',
},
theme: {
theme: 'Theme',

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': '35mm समकक्ष',
zoom: 'जूम इन करें',
sharePhoto: 'फोटो साझा करें',
recipeInfo: 'रेसिपी जानकारी',
recipeCopy: 'रेसिपी पाठ कॉपी करें',
download: 'मूल फ़ाइल डाउनलोड करें',
sharePhoto: 'फोटो साझा करें',
shareCopy: 'लिंक कॉपी करें',
shareTo: 'साझा करें ...',
shareX: 'X पर साझा करें',
shareThreads: 'Threads पर साझा करें',
shareFacebook: 'Facebook पर साझा करें',
shareLinkedIn: 'LinkedIn पर साझा करें',
},
theme: {
theme: 'थीम',

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': 'Setara 35mm',
zoom: 'Perbesar',
sharePhoto: 'Bagikan Foto',
recipeInfo: 'Informasi Resep',
recipeCopy: 'Salin Teks Resep',
download: 'Unduh File Asli',
sharePhoto: 'Bagikan Foto',
shareCopy: 'Salin Tautan',
shareTo: 'Bagikan ...',
shareX: 'Bagikan di X',
shareThreads: 'Bagikan di Threads',
shareFacebook: 'Bagikan di Facebook',
shareLinkedIn: 'Bagikan di LinkedIn',
},
theme: {
theme: 'Tema',

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': 'Equivalente em 35mm',
zoom: 'Aumentar zoom',
sharePhoto: 'Compartilhar Foto',
recipeInfo: 'Informações da receita',
recipeCopy: 'Copiar texto da receita',
download: 'Baixar arquivo original',
sharePhoto: 'Compartilhar Foto',
shareCopy: 'Copiar Link',
shareTo: 'Compartilhar ...',
shareX: 'Compartilhar no X',
shareThreads: 'Compartilhar no Threads',
shareFacebook: 'Compartilhar no Facebook',
shareLinkedIn: 'Compartilhar no LinkedIn',
},
theme: {
theme: 'Tema',

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': 'Equivalente em 35mm',
zoom: 'Aumentar zoom',
sharePhoto: 'Partilhar Fotografia',
recipeInfo: 'Informações da receita',
recipeCopy: 'Copiar texto da receita',
download: 'Descarregar ficheiro original',
sharePhoto: 'Partilhar Fotografia',
shareCopy: 'Copiar Ligação',
shareTo: 'Partilhar ...',
shareX: 'Partilhar no X',
shareThreads: 'Partilhar no Threads',
shareFacebook: 'Partilhar no Facebook',
shareLinkedIn: 'Partilhar no LinkedIn',
},
theme: {
theme: 'Tema',

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': '35mm Eşdeğeri',
zoom: 'Yakınlaştır',
sharePhoto: 'Fotoğrafı Paylaş',
recipeInfo: 'Tarif Bilgisi',
recipeCopy: 'Tarifi Kopyala',
download: 'Orijinal Dosyayı İndir',
sharePhoto: 'Fotoğrafı Paylaş',
shareCopy: 'Bağlantıyı Kopyala',
shareTo: 'Paylaş ...',
shareX: 'X\'te Paylaş',
shareThreads: 'Threads\'te Paylaş',
shareFacebook: 'Facebook\'ta Paylaş',
shareLinkedIn: 'LinkedIn\'de Paylaş',
},
theme: {
theme: 'Tema',

View File

@ -81,10 +81,16 @@ export const TEXT: I18N = {
tooltip: {
'35mm': '35mm 等效焦距',
zoom: '放大',
sharePhoto: '分享照片',
recipeInfo: '预设信息',
recipeCopy: '复制预设文本',
download: '下载原始文件',
sharePhoto: '分享照片',
shareCopy: '复制链接',
shareTo: '分享 ...',
shareX: '在 X 上分享',
shareThreads: '在 Threads 上分享',
shareFacebook: '在 Facebook 上分享',
shareLinkedIn: '在 LinkedIn 上分享',
},
theme: {
theme: '主题',

View File

@ -1,7 +1,7 @@
import Container from '@/components/Container';
import AppGrid from '@/components/AppGrid';
import {
IS_SITE_READY,
IS_APP_READY,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config';
import AdminAppConfiguration from '@/admin/config/AdminAppConfiguration';
@ -32,11 +32,11 @@ export default async function PhotosEmptyState() {
'font-bold text-2xl',
'text-gray-700 dark:text-gray-200',
)}>
{!IS_SITE_READY
{!IS_APP_READY
? appText.onboarding.setupIncomplete
: appText.onboarding.setupComplete}
</div>
{!IS_SITE_READY
{!IS_APP_READY
? <AdminAppConfiguration simplifiedView />
: <div className="max-w-md text-center space-y-6">
<SignInOrUploadClient

View File

@ -1,6 +1,7 @@
/* eslint-disable max-len */
import { Tags } from '@/tag';
import { parseCommaSeparatedKeyString } from '@/utility/key';
export type AiAutoGeneratedField =
'title' |
@ -22,23 +23,13 @@ export const AI_AUTO_GENERATED_FIELDS_DEFAULT: AiAutoGeneratedField[] = [
];
export const parseAiAutoGeneratedFieldsString = (
text = AI_AUTO_GENERATED_FIELDS_DEFAULT.join(','),
): AiAutoGeneratedField[] => {
const textFormatted = text.trim().toLocaleLowerCase();
if (textFormatted === 'none') {
return [];
} else if (textFormatted === 'all') {
return AI_AUTO_GENERATED_FIELDS_ALL;
} else {
const fields = textFormatted
.toLocaleLowerCase()
.split(',')
.map(field => field.trim())
.filter(field => AI_AUTO_GENERATED_FIELDS_ALL
.includes(field as AiAutoGeneratedField));
return fields as AiAutoGeneratedField[];
}
};
string?: string,
) =>
parseCommaSeparatedKeyString({
string,
acceptedKeys: AI_AUTO_GENERATED_FIELDS_ALL,
defaultKeys: AI_AUTO_GENERATED_FIELDS_DEFAULT,
});
export const getAiTextFieldsToGenerate = (
textFieldsToGenerate: AiAutoGeneratedField[],

View File

@ -7,14 +7,20 @@ import { BiCopy } from 'react-icons/bi';
import { ReactNode, useEffect } from 'react';
import { shortenUrl } from '@/utility/url';
import { toastSuccess } from '@/toast';
import { PiXLogo } from 'react-icons/pi';
import { SHOW_SOCIAL } from '@/app/config';
import { generateXPostText } from '@/utility/social';
import { SOCIAL_KEYS } from '@/app/config';
import { useAppState } from '@/app/AppState';
import useOnPathChange from '@/utility/useOnPathChange';
import { IoArrowUp } from 'react-icons/io5';
import MaskedScroll from '@/components/MaskedScroll';
import { useAppText } from '@/i18n/state/client';
import SocialButton from '@/social/SocialButton';
import LoaderButton from '@/components/primitives/LoaderButton';
const BUTTON_COLOR_CLASSNAMES = clsx(
'border-gray-200 bg-gray-50 active:bg-gray-100',
// eslint-disable-next-line max-len
'dark:border-gray-800 dark:bg-gray-900/75 dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
);
export default function ShareModal({
title,
@ -43,24 +49,27 @@ export default function ShareModal({
return () => setShouldRespondToKeyboardCommands?.(true);
}, [setShouldRespondToKeyboardCommands]);
const renderIcon = (
const renderButton = (
icon: ReactNode,
action: () => void,
embedded?: boolean,
tooltip?: string,
) =>
<div
<LoaderButton
className={clsx(
'py-2.5 px-3',
embedded ? 'border-l' : 'border rounded-md',
'border-gray-200 bg-gray-50 active:bg-gray-100',
// eslint-disable-next-line max-len
'dark:border-gray-800 dark:bg-gray-900/75 dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
'flex items-center justify-center h-10',
'px-3',
embedded
? 'border-t-0 border-b-0 border-r-0 rounded-none'
: 'border rounded-md',
BUTTON_COLOR_CLASSNAMES,
'cursor-pointer',
)}
onClick={action}
tooltip={tooltip}
>
{icon}
</div>;
</LoaderButton>;
useOnPathChange(() => setShareModalProps?.(undefined));
@ -78,7 +87,7 @@ export default function ShareModal({
</div>
</div>}
{children}
<div className="flex items-center gap-2">
<div className="flex items-stretch h-10 gap-2">
<div className={clsx(
'rounded-md',
'w-full overflow-hidden',
@ -95,17 +104,29 @@ export default function ShareModal({
{shortenUrl(pathShare)}
</div>
</MaskedScroll>
{renderIcon(
{renderButton(
<BiCopy size={18} />,
() => {
navigator.clipboard.writeText(pathShare);
toastSuccess(appText.photo.copied);
},
true,
appText.tooltip.shareCopy,
)}
</div>
{SOCIAL_KEYS.map(key =>
<SocialButton
key={key}
socialKey={key}
path={pathShare}
text={socialText}
className={clsx(
'h-full',
BUTTON_COLOR_CLASSNAMES,
)}
/>)}
{typeof navigator !== 'undefined' && navigator.share &&
renderIcon(
renderButton(
<IoArrowUp size={18} />,
() => navigator.share({
title: navigatorTitle,
@ -113,14 +134,8 @@ export default function ShareModal({
url: pathShare,
})
.catch(() => console.log('Share canceled')),
)}
{SHOW_SOCIAL &&
renderIcon(
<PiXLogo size={18} />,
() => window.open(
generateXPostText(pathShare, socialText),
'_blank',
),
false,
appText.tooltip.shareTo,
)}
</div>
</div>

View File

@ -0,0 +1,42 @@
import { FaFacebookF, FaLinkedin, FaThreads } from 'react-icons/fa6';
import { urlForSocial, SocialKey, tooltipForSocial } from '.';
import { PiXLogo } from 'react-icons/pi';
import Link from 'next/link';
import clsx from 'clsx';
import Tooltip from '@/components/Tooltip';
import { useAppText } from '@/i18n/state/client';
const iconForSocialKey = (key: SocialKey) => {
switch (key) {
case 'x': return <PiXLogo size={18} />;
case 'threads': return <FaThreads size={18} />;
case 'facebook': return <FaFacebookF size={14} />;
case 'linkedin': return <FaLinkedin size={16} />;
}
};
export default function SocialButton({
socialKey,
path,
text,
className,
}: {
socialKey: SocialKey
path: string
text: string
className?: string
}) {
const appText = useAppText();
return (
<Tooltip content={tooltipForSocial(socialKey, appText)}>
<Link
className={clsx('button', className)}
href={urlForSocial(socialKey, path, text)}
target="_blank"
>
{iconForSocialKey(socialKey)}
</Link>
</Tooltip>
);
}

65
src/social/index.ts Normal file
View File

@ -0,0 +1,65 @@
import { AppTextState } from '@/i18n/state';
import { parseCommaSeparatedKeyString } from '@/utility/key';
export const SOCIAL_KEYS = [
'x',
'threads',
'facebook',
'linkedin',
] as const;
export type SocialKey = (typeof SOCIAL_KEYS)[number];
export const DEFAULT_SOCIAL_KEYS: SocialKey[] = [
'x',
];
export const parseSocialKeysFromString = (string?: string) =>
parseCommaSeparatedKeyString({
string,
acceptedKeys: SOCIAL_KEYS,
defaultKeys: DEFAULT_SOCIAL_KEYS,
});
export const urlForSocial = (
key: SocialKey,
path: string,
text: string,
) => {
switch (key) {
case 'x': {
const url = new URL('https://x.com/intent/tweet');
url.searchParams.set('url', path);
url.searchParams.set('text', text);
return url.toString();
}
case 'threads': {
const url = new URL('https://www.threads.net/intent/post');
url.searchParams.set('text', `${text} ${path}`);
return url.toString();
}
case 'facebook': {
const url = new URL('https://www.facebook.com/sharer/sharer.php');
url.searchParams.set('u', path);
return url.toString();
}
case 'linkedin': {
const url = new URL('https://www.linkedin.com/shareArticle');
url.searchParams.set('url', path);
url.searchParams.set('text', text);
return url.toString();
}
}
};
export const tooltipForSocial = (
key: SocialKey,
{ tooltip }: AppTextState,
) => {
switch (key) {
case 'x': return tooltip.shareX;
case 'threads': return tooltip.shareThreads;
case 'facebook': return tooltip.shareFacebook;
case 'linkedin': return tooltip.shareLinkedIn;
}
};

47
src/utility/key.ts Normal file
View File

@ -0,0 +1,47 @@
const KEY_ALL = 'all';
const KEY_NONE = 'none';
export const parseCommaSeparatedKeyString = <T>({
string: _string,
acceptedKeys,
defaultKeys = [],
}: {
string: string | undefined,
acceptedKeys: readonly T[],
defaultKeys?: T[],
}): T[] => {
const string = (_string ?? '').trim().toLocaleLowerCase();
if (string === KEY_ALL) {
return acceptedKeys.slice();
} else if (string === KEY_NONE) {
return [];
} else {
return string
? string
.split(',')
.map(item => item.trim() as T)
.filter(item => acceptedKeys.includes(item))
: defaultKeys;
}
};
export const getOrderedKeyListStatus = <T>({
selectedKeys,
acceptedKeys,
}: {
selectedKeys: T[],
acceptedKeys: readonly T[],
}): {
label: string,
selected: boolean,
}[] =>
selectedKeys
.map((key, index) => ({
label: `${index + 1}.${key}`,
selected: true,
}))
.concat(acceptedKeys
.filter(key => !selectedKeys.includes(key))
.map(key => ({
label: `* ${key}`,
selected: false,
})));

View File

@ -1,6 +0,0 @@
export const generateXPostText = (path: string, text: string) => {
const url = new URL('https://x.com/intent/tweet');
url.searchParams.set('url', path);
url.searchParams.set('text', text);
return url.toString();
};