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:
parent
25a8a473ab
commit
7c9ce0d26c
10
README.md
10
README.md
@ -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)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
12
package.json
12
package.json
@ -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
657
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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}
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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: 'থিম',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: 'थीम',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '主题',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[],
|
||||
|
||||
@ -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>
|
||||
|
||||
42
src/social/SocialButton.tsx
Normal file
42
src/social/SocialButton.tsx
Normal 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
65
src/social/index.ts
Normal 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
47
src/utility/key.ts
Normal 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,
|
||||
})));
|
||||
@ -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();
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user