Jelajahi Sumber

Neo: show content covers in 16:9 and profile covers in 3:1 on desktop (#5847)

* Neo: show content covers in 16:9 and profile covers in 3:1 on desktop

Content entry covers (posts, polls) had a fixed 30vh height in the view
and in the form preview, and were cropped at the profile cover ratio
(3:1). Every cover now takes its ratio from getCoverAspectClass():

- cover.aspect_ratio ('aspect-video') for content entries: view, form
  preview and crop;
- cover.profile_aspect_ratio ('aspect-video lg:aspect-3/1') for profile
  covers (persons and contexts): the page cover, its crop and the cover
  field of profile edit forms;
- cover.aspect_ratio_by_module still wins over both.

UNA marks the cover input of profile entry forms with `is_profile`, so
NEO crops it at the profile ratio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: address review on cover ratios

- Poll covers take the module ratio, like text entries, so a
  cover.aspect_ratio_by_module entry for bx_polls matches its crop.
- Test that the profile cover input is marked with is_profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: assert the persons cover form in the is_profile test

The Persons module is required by setUp, so a missing form or field is
a failure, not a skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: drop the is_profile form test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 1 Minggu lalu
induk
melakukan
973e892228

+ 3 - 0
modules/base/profile/classes/BxBaseModProfileFormEntry.php

@@ -57,6 +57,9 @@ class BxBaseModProfileFormEntry extends BxBaseModGeneralFormEntry
                 'uploaders_id' => $sUploadersId,
                 'uploaders' => $aUploaders
             );
+
+            // Lets the app crop a profile cover in the profile aspect ratio.
+            $this->aInputs[$CNF['FIELD_COVER']]['is_profile'] = true;
         }
 
         if (($sKey = 'FIELD_BADGE') && !empty($CNF[$sKey]) && isset($this->aInputs[$CNF[$sKey]])) {

+ 3 - 1
neo/packages/app/components/elements/covers/cover-elements.js

@@ -10,6 +10,7 @@ import Profile from 'app/ui/molecules/profile/profile'
 import { NeoButton } from 'app/design/controls/neo-button/neo-button'
 import { fetcher } from 'app/lib/fetcher'
 import { useProfileImageUpload } from 'app/lib/image/use-profile-image-upload'
+import { getCoverAspectClass } from 'app/lib/image/upload-settings'
 import { useCurrentUser } from 'app/context/user'
 import Loading from 'app/ui/atoms/loading'
 import { Platform } from 'react-native'
@@ -102,6 +103,7 @@ export function CoverImage({
         moduleName: profileData?.module || '',
         storage: coverData?.storage || '',
         initialUrl: mode == 'cover' ? coverData?.src : profileData?.url_avatar,
+        isProfile: true,
     })
 
     const handleSwitch = async (id) => {
@@ -119,7 +121,7 @@ export function CoverImage({
             <>
                 <View
                     className={`bg-accent/50 lg:rounded-xl mx-auto gap-2 flex-1 overflow-hidden w-full
-                    ${isCover ? `${appSetting('cover', 'aspect_ratio')}` : 'pb-32'}`}
+                    ${isCover ? getCoverAspectClass(profileData?.module, true) : 'pb-32'}`}
                 >
                     {isCover ? (
                         <Image

+ 4 - 2
neo/packages/app/components/elements/covers/cover-morph-body.js

@@ -14,6 +14,7 @@ import {
 } from './menu-cover'
 import { Platform } from 'react-native'
 import { CoverImage, CoverBackButton } from './cover-elements'
+import { getCoverAspectClass } from 'app/lib/image/upload-settings'
 import { useShowCoverBackButton } from 'app/components/elements/use-cover-back'
 import {
     AVATAR_COLLAPSED,
@@ -88,6 +89,7 @@ export function CoverMorphBody({
     const isMin = coverMode === 'min'
     const isNone = coverMode === 'none'
     const isCoverFixed = !!appSetting('cover', 'fixed')
+    const coverAspect = getCoverAspectClass(profileData?.module, true)
     const coverAllowed = showImage && !isMin && !isNone
     // Same on web and native: hide the cover image once collapsed.
     // Web keeps an empty flow-sized box so sticky collapse can still expand.
@@ -187,7 +189,7 @@ export function CoverMorphBody({
                                 </Row>
                             ) : null}
                             <View
-                                className={`w-full ${appSetting('cover', 'aspect_ratio')}`}
+                                className={`w-full ${coverAspect}`}
                             >
                                 <CoverImage
                                     mode="cover"
@@ -210,7 +212,7 @@ export function CoverMorphBody({
                                 <View className={`${TABLET_MODE_FROM}:hidden w-full h-14`} />
                             ) : null}
                             <View
-                                className={`w-full ${appSetting('cover', 'aspect_ratio')}`}
+                                className={`w-full ${coverAspect}`}
                             />
                         </View>
                     )}

+ 3 - 2
neo/packages/app/components/elements/entity-poll.js

@@ -1,5 +1,6 @@
 import { View, Row } from 'app/design/view';
 import Image, { POST_ENTRY_COVER_SIZES, POST_ENTRY_COVER_WIDTH_CAP } from 'app/ui/atoms/image';
+import { getCoverAspectClass } from 'app/lib/image/upload-settings';
 import { Text, H1 } from 'app/design/typography';
 import { appSetting, getYouTubeVideoId } from 'app/lib/util'
 import Video from 'app/ui/atoms/video';
@@ -123,7 +124,7 @@ export function PollItem({ data, showTitle, onDelete, disabled = false, results_
     );
 }
 
-export default function ElementEntityPoll({ data, blockWrapperProps }) {
+export default function ElementEntityPoll({ data, block, blockWrapperProps }) {
     const videoId = data.video_embed && getYouTubeVideoId(data.video_embed) || null;
     return (
         <BlockWrapper {...blockWrapperProps}><View className="w-full">
@@ -135,7 +136,7 @@ export default function ElementEntityPoll({ data, blockWrapperProps }) {
                     <Youtube videoId={videoId} size={3} />
                 </View>}
                 {(!!data.image && !data.video) && (
-                    <View className="w-full aspect-2/1 rounded-xl overflow-hidden lg:mt-6">
+                    <View className={`w-full ${getCoverAspectClass(block?.module)} rounded-xl overflow-hidden lg:mt-6`}>
                         <Image
                             {...data.image}
                             alt={data.title}

+ 2 - 1
neo/packages/app/components/elements/entity-text.js

@@ -3,6 +3,7 @@ import { Platform } from 'react-native';
 import { useForm, FormProvider } from 'react-hook-form';
 import { View, Pressable } from 'app/design/view';
 import Image, { POST_ENTRY_COVER_SIZES, POST_ENTRY_COVER_WIDTH_CAP } from 'app/ui/atoms/image';
+import { getCoverAspectClass } from 'app/lib/image/upload-settings';
 import Html from 'app/ui/atoms/html';
 import { splitInlineMedia } from 'app/lib/editor/inline-video';
 import { Text, H1C } from 'app/design/typography';
@@ -277,7 +278,7 @@ function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, a
                     <Youtube videoId={videoId} size={3} />
                 </View>}
                 {(!!data.image && !data.video) && (
-                    <View className="w-full h-[30vh] mb-4 sm:rounded-xl overflow-hidden">
+                    <View className={`w-full ${getCoverAspectClass(block?.module)} mb-4 sm:rounded-xl overflow-hidden`}>
                         <Image
                             {...data.image}
                             alt={data.title}

+ 3 - 2
neo/packages/app/components/form-fields/files.js

@@ -775,6 +775,7 @@ export default function (props) {
             sourceHeight={cropSession?.height}
             kind={cropSession?.kind || 'picture'}
             module={formName}
+            isProfile={!!props.is_profile}
             fileSizeBytes={cropSession?.asset?.fileSize || cropSession?.asset?.size}
             onCancel={handleCropCancel}
             onConfirm={handleCropConfirm}
@@ -893,7 +894,7 @@ function ActionButton({ imagesList, props, selectImage, handleDelete, bMultiple,
         if (!img && props.useUrl) {
             img = { file_url: props.value, file_type: "image/jpeg" };
         }
-        let w = 'web:w-full ' + getCoverAspectClass(props.form_name);
+        let w = 'web:w-full ' + getCoverAspectClass(props.form_name, !!props.is_profile);
 
         const isPictureField = props.name == 'picture' || props.name == 'avatar' || props.name == 'badge';
         if (props.name == 'picture' || props.name == 'avatar')
@@ -958,7 +959,7 @@ function GhostsList(imagesList, bMultiple, handleDelete, props) {
     const isCover = props.preview === "cover";
 
     const sizes = [
-        isCover ? "w-full h-[30vh] mb-4 sm:rounded-xl" : "h-20 aspect-video mt-2 mr-2 rounded-lg",
+        isCover ? `w-full ${getCoverAspectClass(props.form_name)} mb-4 sm:rounded-xl` : "h-20 aspect-video mt-2 mr-2 rounded-lg",
         " justify-center items-center overflow-hidden bg-muted  ",
     ].join(" ");
 

+ 3 - 2
neo/packages/app/lib/image/crop-modal.tsx

@@ -90,16 +90,17 @@ export default function ImageCropModal({
     sourceHeight,
     kind = 'picture',
     module,
+    isProfile = false,
     fileSizeBytes,
     onCancel,
     onConfirm,
-}: { visible: any; uri?: any; sourceWidth?: any; sourceHeight?: any; kind?: any; module?: any; fileSizeBytes?: any; onCancel?: () => void; onConfirm?: any }) {
+}: { visible: any; uri?: any; sourceWidth?: any; sourceHeight?: any; kind?: any; module?: any; isProfile?: boolean; fileSizeBytes?: any; onCancel?: () => void; onConfirm?: any }) {
     const { t } = useTranslation()
     const zoomRef = useRef<any>(null)
     const transformRef = useRef<any>(null)
     const [viewport, setViewport] = useState<any>(null)
     const [applying, setApplying] = useState(false)
-    const aspect = getCropAspect(kind, module)
+    const aspect = getCropAspect(kind, module, isProfile)
     const isCircle = kind === 'picture'
 
     useEffect(() => {

+ 4 - 4
neo/packages/app/lib/image/crop.ts

@@ -23,12 +23,12 @@ export function getImageCropKind(fieldName: string) {
     return null
 }
 
-export function getCoverCropAspect(module: string) {
-    return parseAspectRatioClass(getCoverAspectClass(module)) || 3
+export function getCoverCropAspect(module: string, isProfile = false) {
+    return parseAspectRatioClass(getCoverAspectClass(module, isProfile)) || 16 / 9
 }
 
-export function getCropAspect(kind: string, module: string) {
-    return kind === 'cover' ? getCoverCropAspect(module) : 1
+export function getCropAspect(kind: string, module: string, isProfile = false) {
+    return kind === 'cover' ? getCoverCropAspect(module, isProfile) : 1
 }
 
 export function isSvgAsset(asset: any) {

+ 5 - 3
neo/packages/app/lib/image/upload-settings.ts

@@ -51,11 +51,13 @@ export function shouldSkipImageCrop(formName: string) {
     return getImageUploadLimits(formName).skip
 }
 
-export function getCoverAspectClass(formNameOrModule: string) {
-    const module = getModuleFromFormName(formNameOrModule)
+/** Profile covers: the page cover block, and a form cover input UNA marks with `is_profile`. */
+export function getCoverAspectClass(formNameOrModule?: string, isProfile = false) {
+    const module = getModuleFromFormName(formNameOrModule || '')
     const byModule = module ? appSetting('cover', 'aspect_ratio_by_module', module) : ''
     if (byModule) return byModule
-    return appSetting('cover', 'aspect_ratio') || 'aspect-3/1'
+    if (isProfile) return appSetting('cover', 'profile_aspect_ratio') || 'aspect-video lg:aspect-3/1'
+    return appSetting('cover', 'aspect_ratio') || 'aspect-video'
 }
 
 export function getPictureCropOutput() {

+ 3 - 1
neo/packages/app/lib/image/use-profile-image-upload.tsx

@@ -15,7 +15,8 @@ export function useProfileImageUpload({
     moduleName,
     storage,
     initialUrl,
-}: { kind: any; profileId: any; moduleName: any; storage: any; initialUrl: string }) {
+    isProfile = false,
+}: { kind: any; profileId: any; moduleName: any; storage: any; initialUrl: string; isProfile?: boolean }) {
     const [localImageUrl, setLocalImageUrl] = useState<string | undefined>(undefined)
     const [isUploading, setIsUploading] = useState(false)
     const [cropSession, setCropSession] = useState<any>(null)
@@ -84,6 +85,7 @@ export function useProfileImageUpload({
             sourceHeight={cropSession?.height}
             kind={cropSession?.kind || kind}
             module={moduleName}
+            isProfile={isProfile}
             fileSizeBytes={cropSession?.fileSizeBytes}
             onCancel={() => setCropSession(null)}
             onConfirm={handleCropConfirm}

+ 7 - 1
neo/packages/app/settings/elements.js

@@ -12,7 +12,13 @@ export const settingsElements = {
     },
     cover: {
         use_background: true, 
-        aspect_ratio: 'aspect-video lg:aspect-3/1', 
+        // Every cover except profile ones: content entries, their form
+        // previews and crops. Profile covers (persons, groups, events, ...)
+        // use `profile_aspect_ratio`; `aspect_ratio_by_module` wins over both.
+        // The crop takes the first `aspect-N/M` ratio of the class, so a
+        // profile cover crops at 3:1.
+        aspect_ratio: 'aspect-video',
+        profile_aspect_ratio: 'aspect-video lg:aspect-3/1',
         allow_edit: true, 
         allow_switch: true, 
         // Skip CoverMorph collapse (expanded cover stays in flow). Do not