Add description below profile images

This commit is contained in:
MartinBraquet
2025-12-04 19:49:38 +01:00
parent bf4acc09fb
commit 4f8d76f797
6 changed files with 172 additions and 145 deletions

View File

@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS profiles (
has_kids INTEGER,
height_in_inches INTEGER,
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
image_descriptions jsonb,
is_smoker BOOLEAN,
diet TEXT[],
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,

View File

@@ -103,6 +103,7 @@ const optionalProfilesSchema = z.object({
twitter: z.string().optional().nullable(),
university: z.string().optional().nullable(),
website: z.string().optional().nullable(),
image_descriptions: z.any().optional().nullable(),
})
export const combinedProfileSchema =

View File

@@ -758,6 +758,7 @@ export type Database = {
has_kids: number | null
height_in_inches: number | null
id: number
image_descriptions: Json | null
is_smoker: boolean | null
languages: string[] | null
last_modification_time: string
@@ -812,6 +813,7 @@ export type Database = {
has_kids?: number | null
height_in_inches?: number | null
id?: number
image_descriptions?: Json | null
is_smoker?: boolean | null
languages?: string[] | null
last_modification_time?: string
@@ -866,6 +868,7 @@ export type Database = {
has_kids?: number | null
height_in_inches?: number | null
id?: number
image_descriptions?: Json | null
is_smoker?: boolean | null
languages?: string[] | null
last_modification_time?: string

View File

@@ -694,6 +694,13 @@ export const OptionalProfileUserForm = (props: {
pinned_url={profile.pinned_url}
setPhotoUrls={(urls) => setProfile('photo_urls', urls)}
setPinnedUrl={(url) => setProfile('pinned_url', url)}
setDescription={(url, description) =>
setProfile("image_descriptions", {
...(profile?.image_descriptions as Record<string, string> ?? {}),
[url]: description,
})
}
image_descriptions={profile.image_descriptions as Record<string, string>}
/>
</Col>

View File

@@ -1,46 +1,39 @@
import {useState} from 'react'
import clsx from 'clsx'
import Image from 'next/image'
import {buildArray} from 'common/util/array'
import {Carousel} from 'web/components/widgets/carousel'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {Modal} from 'web/components/layout/modal'
import {Col} from 'web/components/layout/col'
import {SignUpButton} from './nav/sidebar'
import {Profile} from 'common/profiles/profile'
import {Button} from 'web/components/buttons/button'
import {updateProfile} from 'web/lib/api'
import {Row} from 'web/components/layout/row'
import {useUser} from 'web/hooks/use-user'
import {PlusIcon} from '@heroicons/react/solid'
import {EditablePhotoGrid} from './widgets/editable-photo-grid'
import {AddPhotosWidget} from './widgets/add-photos'
export default function ProfileCarousel(props: {
profile: Profile,
refreshProfile: () => void,
}) {
const {profile, refreshProfile} = props
const {profile} = props
const photoNums = profile.photo_urls ? profile.photo_urls.length : 0
const [lightboxUrl, setLightboxUrl] = useState('')
const [lightboxOpen, setLightboxOpen] = useState(false)
const [isEditMode, setIsEditMode] = useState(false)
const [addPhotosOpen, setAddPhotosOpen] = useState(false)
// const [isEditMode, setIsEditMode] = useState(false)
// const [addPhotosOpen, setAddPhotosOpen] = useState(false)
const [pinnedUrl, setPinnedUrl] = useState<string | null>(profile.pinned_url)
const [photoUrls, setPhotoUrls] = useState<string[]>(profile.photo_urls ?? [])
// const [pinnedUrl, setPinnedUrl] = useState<string | null>(profile.pinned_url)
// const [photoUrls, setPhotoUrls] = useState<string[]>(profile.photo_urls ?? [])
const currentUser = useUser()
const isCurrentUser = currentUser?.id === profile.user_id
// const isCurrentUser = currentUser?.id === profile.user_id
const handleSaveChanges = async () => {
await updateProfile({
pinned_url: pinnedUrl ?? undefined,
photo_urls: photoUrls,
})
setIsEditMode(false)
refreshProfile()
}
// const handleSaveChanges = async () => {
// await updateProfile({
// pinned_url: pinnedUrl ?? undefined,
// photo_urls: photoUrls,
// })
// setIsEditMode(false)
// refreshProfile()
// }
if (!currentUser && profile.visibility !== 'public') {
return (
@@ -78,82 +71,83 @@ export default function ProfileCarousel(props: {
return (
<>
<div className="flex gap-2 self-end">
{isCurrentUser && !isEditMode && (
<Button
onClick={() => setIsEditMode(true)}
color="gray-outline"
size="sm"
>
Edit photos
</Button>
)}
{isCurrentUser && isEditMode && (
<Row className="gap-2">
<Button
onClick={() => {
// TODO this is stale if you've saved
setPhotoUrls(profile.photo_urls ?? [])
setPinnedUrl(profile.pinned_url)
setIsEditMode(false)
}}
color="gray-outline"
size="sm"
>
Cancel
</Button>
<Button onClick={handleSaveChanges} size="sm">
Save changes
</Button>
</Row>
)}
</div>
{/*<div className="flex gap-2 self-end">*/}
{/* {isCurrentUser && !isEditMode && (*/}
{/* <Button*/}
{/* onClick={() => setIsEditMode(true)}*/}
{/* color="gray-outline"*/}
{/* size="sm"*/}
{/* >*/}
{/* Edit photos*/}
{/* </Button>*/}
{/* )}*/}
{/* {isCurrentUser && isEditMode && (*/}
{/* <Row className="gap-2">*/}
{/* <Button*/}
{/* onClick={() => {*/}
{/* // TODO this is stale if you've saved*/}
{/* setPhotoUrls(profile.photo_urls ?? [])*/}
{/* setPinnedUrl(profile.pinned_url)*/}
{/* setIsEditMode(false)*/}
{/* }}*/}
{/* color="gray-outline"*/}
{/* size="sm"*/}
{/* >*/}
{/* Cancel*/}
{/* </Button>*/}
{/* <Button onClick={handleSaveChanges} size="sm">*/}
{/* Save changes*/}
{/* </Button>*/}
{/* </Row>*/}
{/* )}*/}
{/*</div>*/}
{isEditMode ? (
<Col className="gap-4">
<EditablePhotoGrid
photos={buildArray(pinnedUrl, photoUrls)}
onReorder={(newOrder) => {
const newPinnedUrl = newOrder[0]
const newPhotoUrls = newOrder.filter(
(url) => url !== newPinnedUrl
)
setPinnedUrl(newPinnedUrl)
setPhotoUrls(newPhotoUrls)
}}
onDelete={(url) => {
if (url === pinnedUrl) {
const newPhotos = photoUrls.filter((u) => u !== url)
setPinnedUrl(newPhotos[0] ?? null)
setPhotoUrls(newPhotos.slice(1))
} else {
setPhotoUrls(photoUrls.filter((u) => u !== url))
}
}}
onSetProfilePic={(url) => {
if (url === pinnedUrl) return
setPinnedUrl(url)
setPhotoUrls(
[...photoUrls.filter((u) => u !== url), pinnedUrl].filter(
Boolean
) as string[]
)
}}
/>
<Button
onClick={() => setAddPhotosOpen(true)}
color="gray-outline"
size="sm"
className="self-start"
>
<PlusIcon className="mr-1 h-5 w-5"/>
Add photos
</Button>
</Col>
) : (
<Carousel>
{buildArray(profile.pinned_url, profile.photo_urls).map((url, i) => (
<div key={url} className="h-[300px] w-[300px] flex-none snap-start">
{/*{isEditMode ? (*/}
{/* <Col className="gap-4">*/}
{/* <EditablePhotoGrid*/}
{/* photos={buildArray(pinnedUrl, photoUrls)}*/}
{/* onReorder={(newOrder) => {*/}
{/* const newPinnedUrl = newOrder[0]*/}
{/* const newPhotoUrls = newOrder.filter(*/}
{/* (url) => url !== newPinnedUrl*/}
{/* )*/}
{/* setPinnedUrl(newPinnedUrl)*/}
{/* setPhotoUrls(newPhotoUrls)*/}
{/* }}*/}
{/* onDelete={(url) => {*/}
{/* if (url === pinnedUrl) {*/}
{/* const newPhotos = photoUrls.filter((u) => u !== url)*/}
{/* setPinnedUrl(newPhotos[0] ?? null)*/}
{/* setPhotoUrls(newPhotos.slice(1))*/}
{/* } else {*/}
{/* setPhotoUrls(photoUrls.filter((u) => u !== url))*/}
{/* }*/}
{/* }}*/}
{/* onSetProfilePic={(url) => {*/}
{/* if (url === pinnedUrl) return*/}
{/* setPinnedUrl(url)*/}
{/* setPhotoUrls(*/}
{/* [...photoUrls.filter((u) => u !== url), pinnedUrl].filter(*/}
{/* Boolean*/}
{/* ) as string[]*/}
{/* )*/}
{/* }}*/}
{/* />*/}
{/* <Button*/}
{/* onClick={() => setAddPhotosOpen(true)}*/}
{/* color="gray-outline"*/}
{/* size="sm"*/}
{/* className="self-start"*/}
{/* >*/}
{/* <PlusIcon className="mr-1 h-5 w-5"/>*/}
{/* Add photos*/}
{/* </Button>*/}
{/* </Col>*/}
{/*) : (*/}
<Carousel>
{buildArray(profile.pinned_url, profile.photo_urls).map((url, i) => (
<Col key={url}>
<div className="h-[300px] w-[300px] flex-none snap-start">
<Image
priority={i < 3}
src={url}
@@ -168,43 +162,47 @@ export default function ProfileCarousel(props: {
}}
/>
</div>
))}
{isCurrentUser && (profile.photo_urls?.length ?? 0) > 1 && (
<button
className="bg-ink-200 text-ink-0 group flex h-[300px] w-[300px] flex-none cursor-pointer snap-start items-center justify-center rounded ease-in-out"
onClick={() => setAddPhotosOpen(true)}
>
<PlusIcon className="w-20 transition-all group-hover:w-24"/>
</button>
)}
</Carousel>
)}
<p className="mt-2 px-4 py-1 text-sm w-[300px] whitespace-pre-wrap">
{(profile.image_descriptions as Record<string, string>)?.[url]}
</p>
</Col>
))}
{/*{isCurrentUser && (profile.photo_urls?.length ?? 0) > 1 && (*/}
{/* <button*/}
{/* className="bg-ink-200 text-ink-0 group flex h-[300px] w-[300px] flex-none cursor-pointer snap-start items-center justify-center rounded ease-in-out"*/}
{/* onClick={() => setAddPhotosOpen(true)}*/}
{/* >*/}
{/* <PlusIcon className="w-20 transition-all group-hover:w-24"/>*/}
{/* </button>*/}
{/*)}*/}
</Carousel>
{/* )}*/}
<Modal open={lightboxOpen} setOpen={setLightboxOpen}>
<Image src={lightboxUrl} width={1000} height={1000} alt=""/>
</Modal>
{isCurrentUser && (
<Modal open={addPhotosOpen} setOpen={setAddPhotosOpen}>
<Col className={clsx(MODAL_CLASS)}>
<AddPhotosWidget
user={currentUser}
photo_urls={photoUrls}
pinned_url={pinnedUrl}
setPhotoUrls={setPhotoUrls}
setPinnedUrl={setPinnedUrl}
/>
<Row className="gap-4 self-end">
<Button
color="gray-outline"
onClick={() => setAddPhotosOpen(false)}
>
Done
</Button>
</Row>
</Col>
</Modal>
)}
{/*{isCurrentUser && (*/}
{/* <Modal open={addPhotosOpen} setOpen={setAddPhotosOpen}>*/}
{/* <Col className={clsx(MODAL_CLASS)}>*/}
{/* <AddPhotosWidget*/}
{/* user={currentUser}*/}
{/* photo_urls={photoUrls}*/}
{/* pinned_url={pinnedUrl}*/}
{/* setPhotoUrls={setPhotoUrls}*/}
{/* setPinnedUrl={setPinnedUrl}*/}
{/* />*/}
{/* <Row className="gap-4 self-end">*/}
{/* <Button*/}
{/* color="gray-outline"*/}
{/* onClick={() => setAddPhotosOpen(false)}*/}
{/* >*/}
{/* Done*/}
{/* </Button>*/}
{/* </Row>*/}
{/* </Col>*/}
{/* </Modal>*/}
{/*)}*/}
</>
)
}

View File

@@ -1,29 +1,31 @@
import { CheckCircleIcon } from '@heroicons/react/outline'
import { XIcon } from '@heroicons/react/solid'
import {CheckCircleIcon} from '@heroicons/react/outline'
import {PlusIcon, XIcon} from '@heroicons/react/solid'
import Image from 'next/image'
import { uniq } from 'lodash'
import { useState } from 'react'
import {uniq} from 'lodash'
import {useState} from 'react'
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { Button } from 'web/components/buttons/button'
import { uploadImage } from 'web/lib/firebase/storage'
import { buildArray } from 'common/util/array'
import { Row } from 'web/components/layout/row'
import { User } from 'common/user'
import { PlusIcon } from '@heroicons/react/solid'
import {Col} from 'web/components/layout/col'
import {Button} from 'web/components/buttons/button'
import {uploadImage} from 'web/lib/firebase/storage'
import {buildArray} from 'common/util/array'
import {Row} from 'web/components/layout/row'
import {User} from 'common/user'
export const AddPhotosWidget = (props: {
user: User
image_descriptions: Record<string, string> | null
photo_urls: string[] | null
pinned_url: string | null
setPhotoUrls: (urls: string[]) => void
setPinnedUrl: (url: string) => void
setDescription: (url: string, description: string) => void
}) => {
const { user, photo_urls, pinned_url, setPhotoUrls, setPinnedUrl } = props
const {user, photo_urls, pinned_url, setPhotoUrls, setPinnedUrl, setDescription, image_descriptions} = props
const [uploadingImages, setUploadingImages] = useState(false)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
@@ -56,11 +58,11 @@ export const AddPhotosWidget = (props: {
<Row className="flex-wrap gap-2">
<label
className={clsx(
'bg-canvas-50 hover:bg-ink-300 text-ink-0 dark:text-ink-500 hover:dark:text-ink-600 flex h-[100px] w-[100px] cursor-pointer flex-col items-center rounded-md transition-colors'
'bg-canvas-50 hover:bg-ink-300 text-ink-0 dark:text-ink-500 hover:dark:text-ink-600 flex h-[200px] w-[200px] cursor-pointer flex-col items-center rounded-md transition-colors'
)}
htmlFor="photo-upload"
>
<PlusIcon className=" mx-auto my-auto h-16 w-16 text-gray-500" />
<PlusIcon className=" mx-auto my-auto h-16 w-16 text-gray-500"/>
</label>
{uniq(buildArray(pinned_url, photo_urls))?.map((url, index) => {
const isPinned = url === pinned_url
@@ -100,14 +102,29 @@ export const AddPhotosWidget = (props: {
'bg-canvas-0 absolute right-0 top-0 !rounded-full !px-1 py-1'
)}
>
<XIcon className={'h-4 w-4'} />
<XIcon className={'h-4 w-4'}/>
</Button>
<Image
src={url}
width={80}
height={80}
alt={`preview ${index}`}
className="h-20 w-20 object-cover"
className="h-[200px] w-[200px] object-cover"
/>
<textarea
// stop click bubbling so clicking/focusing the input doesn't pin the image
onClick={(e) => e.stopPropagation()}
aria-label={`description for image ${index}`}
placeholder="Add description"
value={image_descriptions?.[url] ?? ''}
onChange={(e) => {
e.stopPropagation()
const v = e.target.value
setDescription(url, v)
}}
rows={3}
className="mt-2 w-[200px] rounded border px-2 py-1 text-sm focus:outline-none bg-canvas-50 resize-none overflow-y-auto"
/>
</div>
)