Combine visibility setting into single dropdown (#281)

This commit is contained in:
Sam Becker 2025-07-10 09:58:27 -05:00 committed by GitHub
parent 468f7fe3d0
commit 9cea328386
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 658 additions and 218 deletions

View File

@ -3,7 +3,7 @@
import PhotoCamera from '@/camera/PhotoCamera'; import PhotoCamera from '@/camera/PhotoCamera';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid'; import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import EntityLink from '@/components/entity/EntityLink'; import EntityLink from '@/components/entity/EntityLink';
import LabeledIcon from '@/components/primitives/LabeledIcon'; import LabeledIcon from '@/components/primitives/LabeledIcon';
@ -45,13 +45,13 @@ export default function ComponentsPage() {
'flex gap-1', 'flex gap-1',
'*:inline-flex *:gap-1 [&_input]:-translate-y-0.5', '*:inline-flex *:gap-1 [&_input]:-translate-y-0.5',
)}> )}>
<FieldSetWithStatus <FieldsetWithStatus
label="Grid" label="Grid"
type="checkbox" type="checkbox"
value={shouldShowBaselineGrid ? 'true' : 'false'} value={shouldShowBaselineGrid ? 'true' : 'false'}
onChange={e => setShouldShowBaselineGrid?.(e === 'true')} onChange={e => setShouldShowBaselineGrid?.(e === 'true')}
/> />
<FieldSetWithStatus <FieldsetWithStatus
label="Components" label="Components"
type="checkbox" type="checkbox"
value={debugComponents ? 'true' : 'false'} value={debugComponents ? 'true' : 'false'}

View File

@ -1,20 +1,110 @@
'use client'; 'use client';
import PhotoTagFieldset from '@/admin/PhotoTagFieldset';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import IconHidden from '@/components/icons/IconHidden';
import IconLock from '@/components/icons/IconLock';
import SelectMenu from '@/components/SelectMenu';
import StatusIcon from '@/components/StatusIcon'; import StatusIcon from '@/components/StatusIcon';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { useState } from 'react';
export default function ComponentsPage() { export default function ComponentsPage() {
const [value, setValue] = useState('visible');
return ( return (
<AppGrid <AppGrid
contentMain={<div className={clsx( contentMain={<div className="flex flex-col gap-4">
'flex gap-0.5', <div className={clsx(
'*:inline-flex *:bg-medium', 'flex gap-0.5',
)}> '*:inline-flex *:bg-medium',
<StatusIcon type="checked" /> )}>
<StatusIcon type="missing" /> <StatusIcon type="checked" />
<StatusIcon type="warning" /> <StatusIcon type="missing" />
<StatusIcon type="optional" /> <StatusIcon type="warning" />
<StatusIcon type="optional" />
</div>
<div className="z-12">
<PhotoTagFieldset
tags="tag-1"
tagOptions={[{
tag: 'Tag 1',
count: 1,
lastModified: new Date(),
}, {
tag: 'Tag 2',
count: 1,
lastModified: new Date(),
}]}
onChange={() => {}}
onError={() => {}}
openOnLoad={false}
/>
</div>
<div className="z-11">
<FieldsetWithStatus
label="Select"
value="tag-1"
selectOptions={[{
value: 'tag-1',
label: 'Tag 1',
}, {
value: 'tag-2',
label: 'Tag 2',
}]}
onChange={() => {}}
/>
</div>
<div className="z-9 mt-12">
<SelectMenu
name="select-menu"
value={value}
onChange={setValue}
options={[{
value: 'visible',
accessoryStart: <IconHidden size={15} visible />,
label: 'Always visible',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'hidden',
accessoryStart: <IconHidden size={15} />,
label: 'Hide from feeds',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private1',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private4',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private2',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}, {
value: 'private3',
accessoryStart: <IconLock size={14} />,
label: 'Private',
accessoryEnd: '× 2',
note: 'Exclude photo from core feeds',
}]}
/>
</div>
</div>} </div>}
/> />
); );

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import ErrorNote from '@/components/ErrorNote'; import ErrorNote from '@/components/ErrorNote';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import Container from '@/components/Container'; import Container from '@/components/Container';
import { addUploadsAction } from '@/photo/actions'; import { addUploadsAction } from '@/photo/actions';
import { PATH_ADMIN_PHOTOS } from '@/app/paths'; import { PATH_ADMIN_PHOTOS } from '@/app/paths';
@ -13,7 +13,7 @@ import {
import sleep from '@/utility/sleep'; import sleep from '@/utility/sleep';
import { readStreamableValue } from 'ai/rsc'; import { readStreamableValue } from 'ai/rsc';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import { Dispatch, SetStateAction, useRef, useState } from 'react';
import { BiCheckCircle } from 'react-icons/bi'; import { BiCheckCircle } from 'react-icons/bi';
import ProgressButton from '@/components/primitives/ProgressButton'; import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient'; import { UrlAddStatus } from './AdminUploadsClient';
@ -22,9 +22,9 @@ import DeleteUploadButton from './DeleteUploadButton';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { pluralize } from '@/utility/string'; import { pluralize } from '@/utility/string';
import FieldsetFavs from '@/photo/form/FieldsetFavs'; import FieldsetFavs from '@/photo/form/FieldsetFavs';
import FieldsetPrivate from '@/photo/form/FieldsetPrivate';
import IconAddUpload from '@/components/icons/IconAddUpload'; import IconAddUpload from '@/components/icons/IconAddUpload';
import FieldsetExclude from '@/photo/form/FieldsetExclude'; import { PhotoFormData } from '@/photo/form';
import FieldsetVisibility from '@/photo/visibility/FieldsetVisibility';
const UPLOAD_BATCH_SIZE = 2; const UPLOAD_BATCH_SIZE = 2;
@ -52,11 +52,8 @@ export default function AdminBatchUploadActions({
const { updateAdminData } = useAppState(); const { updateAdminData } = useAppState();
const [showBulkSettings, setShowBulkSettings] = useState(false); const [showBulkSettings, setShowBulkSettings] = useState(false);
const [tags, setTags] = useState('');
const [favorite, setFavorite] = useState('false');
const [excludeFromFeeds, setExcludeFromFeeds] = useState('false');
const [hidden, setHidden] = useState('false');
const [tagErrorMessage, setTagErrorMessage] = useState(''); const [tagErrorMessage, setTagErrorMessage] = useState('');
const [formData, setFormData] = useState<Partial<PhotoFormData>>({});
const [buttonText, setButtonText] = useState('Add All Uploads'); const [buttonText, setButtonText] = useState('Add All Uploads');
const [actionErrorMessage, setActionErrorMessage] = useState(''); const [actionErrorMessage, setActionErrorMessage] = useState('');
@ -71,6 +68,7 @@ export default function AdminBatchUploadActions({
titles: string[], titles: string[],
isFinalBatch: boolean, isFinalBatch: boolean,
) => { ) => {
const { tags, favorite, excludeFromFeeds, hidden } = formData;
try { try {
const stream = await addUploadsAction({ const stream = await addUploadsAction({
uploadUrls: urls, uploadUrls: urls,
@ -126,13 +124,6 @@ export default function AdminBatchUploadActions({
} }
}; };
useEffect(() => {
if (hidden === 'true') {
setFavorite('false');
setExcludeFromFeeds('false');
}
}, [hidden]);
return ( return (
<> <>
{actionErrorMessage && {actionErrorMessage &&
@ -145,7 +136,7 @@ export default function AdminBatchUploadActions({
? `Apply to ${pluralize(uploadUrls.length, 'upload')}` ? `Apply to ${pluralize(uploadUrls.length, 'upload')}`
: `Found ${pluralize(uploadUrls.length, 'upload')}`} : `Found ${pluralize(uploadUrls.length, 'upload')}`}
</div> </div>
<FieldSetWithStatus <FieldsetWithStatus
label="Apply to All" label="Apply to All"
type="checkbox" type="checkbox"
value={showBulkSettings ? 'true' : 'false'} value={showBulkSettings ? 'true' : 'false'}
@ -157,30 +148,25 @@ export default function AdminBatchUploadActions({
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<PhotoTagFieldset <PhotoTagFieldset
label="Tags" label="Tags"
tags={tags} tags={formData.tags ?? ''}
tagOptions={uniqueTags} tagOptions={uniqueTags}
onChange={setTags} onChange={tags => setFormData(data => ({ ...data, tags }))}
onError={setTagErrorMessage} onError={setTagErrorMessage}
readOnly={isAdding} readOnly={isAdding}
className="relative z-10" className="relative z-10"
/> />
<div className="flex max-sm:flex-col gap-x-8 gap-y-4"> <FieldsetVisibility
<FieldsetFavs formData={formData}
value={favorite} setFormData={setFormData}
onChange={setFavorite} readOnly={isAdding}
readOnly={isAdding || hidden === 'true'} />
/> <FieldsetFavs
<FieldsetExclude className="my-6"
value={excludeFromFeeds} value={formData.favorite ?? 'false'}
onChange={setExcludeFromFeeds} onChange={favorite =>
readOnly={isAdding || hidden === 'true'} setFormData(data => ({ ...data, favorite }))}
/> readOnly={isAdding}
<FieldsetPrivate />
value={hidden}
onChange={setHidden}
readOnly={isAdding}
/>
</div>
</div>} </div>}
<div className="flex flex-col sm:flex-row-reverse gap-2"> <div className="flex flex-col sm:flex-row-reverse gap-2">
<ProgressButton <ProgressButton

View File

@ -24,7 +24,7 @@ const ADMIN_INFO_PAGE_WITHOUT_INSIGHTS = [{
path: PATH_ADMIN_CONFIGURATION, path: PATH_ADMIN_CONFIGURATION,
}] as typeof ADMIN_INFO_PAGES; }] as typeof ADMIN_INFO_PAGES;
export default function AdminInfoPage({ export default function AdminInfoNav({
includeInsights, includeInsights,
}: { }: {
includeInsights: boolean includeInsights: boolean

View File

@ -14,10 +14,9 @@ import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton'; import PhotoSyncButton from './PhotoSyncButton';
import DeletePhotoButton from './DeletePhotoButton'; import DeletePhotoButton from './DeletePhotoButton';
import { Timezone } from '@/utility/timezone'; import { Timezone } from '@/utility/timezone';
import IconHidden from '@/components/icons/IconHidden';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync'; import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync';
import IconLock from '@/components/icons/IconLock'; import PhotoVisibilityIcon from '@/photo/visibility/PhotoVisibilityIcon';
export default function AdminPhotosTable({ export default function AdminPhotosTable({
photos, photos,
@ -77,20 +76,9 @@ export default function AdminPhotosTable({
<span className="truncate"> <span className="truncate">
{titleForPhoto(photo, false)} {titleForPhoto(photo, false)}
</span> </span>
{photo.excludeFromFeeds && !photo.hidden && <span className="inline-flex items-center">
<span> <PhotoVisibilityIcon photo={photo} />
<IconHidden </span>
className="inline translate-y-[-1px]"
size={16}
/>
</span>}
{photo.hidden &&
<span>
<IconLock
size={13}
className="inline translate-y-[-1.5px]"
/>
</span>}
</span> </span>
{photo.priorityOrder !== null && {photo.priorityOrder !== null &&
<span className={clsx( <span className={clsx(

View File

@ -3,7 +3,7 @@
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link'; import Link from 'next/link';
import { PATH_ADMIN_RECIPES } from '@/app/paths'; import { PATH_ADMIN_RECIPES } from '@/app/paths';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { ReactNode, useMemo, useState } from 'react'; import { ReactNode, useMemo, useState } from 'react';
import { renamePhotoRecipeGloballyAction } from '@/photo/actions'; import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
@ -34,7 +34,7 @@ export default function AdminRecipeForm({
action={renamePhotoRecipeGloballyAction} action={renamePhotoRecipeGloballyAction}
className="space-y-8" className="space-y-8"
> >
<FieldSetWithStatus <FieldsetWithStatus
label="New Recipe Name" label="New Recipe Name"
value={updatedRecipeRaw} value={updatedRecipeRaw}
onChange={setUpdatedRecipeRaw} onChange={setUpdatedRecipeRaw}

View File

@ -3,7 +3,7 @@
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link'; import Link from 'next/link';
import { PATH_ADMIN_TAGS } from '@/app/paths'; import { PATH_ADMIN_TAGS } from '@/app/paths';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { ReactNode, useMemo, useState } from 'react'; import { ReactNode, useMemo, useState } from 'react';
import { renamePhotoTagGloballyAction } from '@/photo/actions'; import { renamePhotoTagGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
@ -34,7 +34,7 @@ export default function AdminTagForm({
action={renamePhotoTagGloballyAction} action={renamePhotoTagGloballyAction}
className="space-y-8" className="space-y-8"
> >
<FieldSetWithStatus <FieldsetWithStatus
label="New Tag Name" label="New Tag Name"
value={updatedTagRaw} value={updatedTagRaw}
onChange={setUpdatedTagRaw} onChange={setUpdatedTagRaw}

View File

@ -12,7 +12,7 @@ import { pathForAdminUploadUrl } from '@/app/paths';
import DeleteUploadButton from './DeleteUploadButton'; import DeleteUploadButton from './DeleteUploadButton';
import { Dispatch, SetStateAction, useEffect, useRef } from 'react'; import { Dispatch, SetStateAction, useEffect, useRef } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom'; import { isElementEntirelyInViewport } from '@/utility/dom';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import EditButton from './EditButton'; import EditButton from './EditButton';
import AddUploadButton from './AddUploadButton'; import AddUploadButton from './AddUploadButton';
@ -105,7 +105,7 @@ export default function AdminUploadsTableRow({
)}> )}>
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col gap-6 w-full">
<div className="flex flex-col grow gap-2"> <div className="flex flex-col grow gap-2">
<FieldSetWithStatus <FieldsetWithStatus
label="Title" label="Title"
value={draftTitle} value={draftTitle}
onChange={titleUpdated => onChange={titleUpdated =>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag'; import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
import { ComponentProps, useEffect, useRef, useState } from 'react'; import { ComponentProps, useEffect, useRef, useState } from 'react';
@ -12,7 +12,7 @@ export default function PhotoTagFieldset(props: {
onError?: (error: string) => void onError?: (error: string) => void
openOnLoad?: boolean openOnLoad?: boolean
} & Partial<Omit< } & Partial<Omit<
ComponentProps<typeof FieldSetWithStatus>, ComponentProps<typeof FieldsetWithStatus>,
'tagOptions' 'tagOptions'
>>) { >>) {
const { const {
@ -41,7 +41,7 @@ export default function PhotoTagFieldset(props: {
return ( return (
<div ref={ref}> <div ref={ref}>
<FieldSetWithStatus <FieldsetWithStatus
{...rest} {...rest}
inputRef={ref} inputRef={ref}
label="Tags" label="Tags"

View File

@ -118,7 +118,13 @@ export default function AppViewSwitcher({
return ( return (
<div className={clsx('flex', className)}> <div className={clsx('flex', className)}>
<Switcher className={GAP_CLASS}> <Switcher
className={clsx(
GAP_CLASS,
// Apply offset due to outline strategy
'translate-x-[1px]',
)}
>
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull} {GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull}
{GRID_HOMEPAGE_ENABLED ? renderItemFull : renderItemGrid} {GRID_HOMEPAGE_ENABLED ? renderItemFull : renderItemGrid}
{/* Show spinner if admin is suspected to be logged in */} {/* Show spinner if admin is suspected to be logged in */}

View File

@ -23,7 +23,10 @@ export default function ThemeSwitcher () {
} }
return ( return (
<Switcher> <Switcher
// Apply offset due to outline strategy
className="translate-x-[-1px]"
>
<SwitcherItem <SwitcherItem
icon={<BiDesktop size={16} />} icon={<BiDesktop size={16} />}
onClick={() => setTheme('system')} onClick={() => setTheme('system')}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import Container from '@/components/Container'; import Container from '@/components/Container';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { import {
@ -92,7 +92,7 @@ export default function SignInForm({
{appText.auth.invalidEmailPassword} {appText.auth.invalidEmailPassword}
</ErrorNote>} </ErrorNote>}
<div className="space-y-4 w-full"> <div className="space-y-4 w-full">
<FieldSetWithStatus <FieldsetWithStatus
id="email" id="email"
inputRef={emailRef} inputRef={emailRef}
label={appText.auth.email} label={appText.auth.email}
@ -100,7 +100,7 @@ export default function SignInForm({
value={email} value={email}
onChange={setEmail} onChange={setEmail}
/> />
<FieldSetWithStatus <FieldsetWithStatus
id="password" id="password"
label={appText.auth.password} label={appText.auth.password}
type="password" type="password"

View File

@ -48,7 +48,6 @@ import { signOutAction } from '@/auth/actions';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate'; import PhotoDate from '@/photo/PhotoDate';
import PhotoSmall from '@/photo/PhotoSmall'; import PhotoSmall from '@/photo/PhotoSmall';
import { FaCheck } from 'react-icons/fa6';
import { import {
addPrivateToTags, addPrivateToTags,
formatTag, formatTag,
@ -90,6 +89,7 @@ import IconRecents from '@/components/icons/IconRecents';
import { CgFileDocument } from 'react-icons/cg'; import { CgFileDocument } from 'react-icons/cg';
import { FaRegUserCircle } from 'react-icons/fa'; import { FaRegUserCircle } from 'react-icons/fa';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import IconCheck from '@/components/icons/IconCheck';
const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -123,7 +123,7 @@ const renderToggle = (
): CommandKItem => ({ ): CommandKItem => ({
label: `Toggle ${label}`, label: `Toggle ${label}`,
action: () => onToggle?.(prev => !prev), action: () => onToggle?.(prev => !prev),
annotation: isEnabled ? <FaCheck size={12} /> : undefined, annotation: isEnabled ? <IconCheck size={12} /> : undefined,
}); });
export default function CommandKClient({ export default function CommandKClient({

View File

@ -6,13 +6,14 @@ import Spinner from './Spinner';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { FieldSetType, AnnotatedTag } from '@/photo/form'; import { FieldSetType, AnnotatedTag } from '@/photo/form';
import TagInput from './TagInput'; import TagInput from './TagInput';
import { FiChevronDown } from 'react-icons/fi';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import Checkbox from './Checkbox'; import Checkbox from './Checkbox';
import ResponsiveText from './primitives/ResponsiveText'; import ResponsiveText from './primitives/ResponsiveText';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
import { SelectMenuOptionType } from './SelectMenuOption';
import SelectMenu from './SelectMenu';
export default function FieldSetWithStatus({ export default function FieldsetWithStatus({
id: _id, id: _id,
label, label,
icon, icon,
@ -53,7 +54,7 @@ export default function FieldSetWithStatus({
isModified?: boolean isModified?: boolean
onChange?: (value: string) => void onChange?: (value: string) => void
className?: string className?: string
selectOptions?: { value: string, label: string }[] selectOptions?: SelectMenuOptionType[]
selectOptionsDefaultLabel?: string selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[] tagOptions?: AnnotatedTag[]
tagOptionsLimit?: number tagOptionsLimit?: number
@ -178,40 +179,18 @@ export default function FieldSetWithStatus({
</label>} </label>}
<div className="flex gap-2"> <div className="flex gap-2">
{selectOptions {selectOptions
? <div className="relative w-full"> ? <SelectMenu
<select id={id}
id={id} name={id}
name={id} tabIndex={tabIndex}
value={value} className="w-full"
onChange={e => onChange?.(e.target.value)} value={value}
className={clsx( onChange={onChange}
'w-full', options={selectOptions}
clsx(Boolean(error) && 'error'), defaultOptionLabel={selectOptionsDefaultLabel}
// Use special class because `select` can't be readonly error={error}
readOnly || pending && 'disabled-select', readOnly={readOnly || pending || loading}
)} />
>
{selectOptionsDefaultLabel &&
<option value="">{selectOptionsDefaultLabel}</option>}
{selectOptions.map(({
value: optionValue,
label: optionLabel,
}) =>
<option
key={optionValue}
value={optionValue}
>
{optionLabel}
</option>)}
</select>
<div className={clsx(
'absolute top-0 right-3 z-10 pointer-events-none',
'flex h-full items-center',
'text-extra-dim text-2xl',
)}>
<FiChevronDown />
</div>
</div>
: tagOptions : tagOptions
? <TagInput ? <TagInput
id={id} id={id}

View File

@ -0,0 +1,222 @@
import clsx from 'clsx/lite';
import { ReactNode, useEffect, useRef, useState } from 'react';
import MaskedScroll from './MaskedScroll';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
import IconSelectChevron from './icons/IconSelectChevron';
import SelectMenuOption, { SelectMenuOptionType } from './SelectMenuOption';
const LISTENER_KEY_MOUSE_MOVE = 'mousemove';
const LISTENER_KEY_KEYDOWN = 'keydown';
export default function SelectMenu({
id,
name,
value,
className,
onChange,
options,
defaultOptionLabel,
tabIndex,
error,
readOnly,
children,
}: {
id?: string
name: string
value: string
className?: string
onChange?: (value: string) => void
options: SelectMenuOptionType[]
defaultOptionLabel?: string
tabIndex?: number
error?: string
readOnly?: boolean
children?: ReactNode
}) {
const ARIA_ID_SELECT_OPTIONS = `select-options-${name}`;
const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
const [shouldHighlightOnHover, setShouldHighlightOnHover] = useState(true);
const selectedOption = options.find(o => o.value === value);
useClickInsideOutside({
htmlElements: [ref],
onClickOutside: () => setIsOpen(false),
});
useEffect(() => {
if (readOnly) {
setIsOpen(false);
}
}, [readOnly]);
// Setup keyboard listener
useEffect(() => {
const listener = (e: KeyboardEvent) => {
// Keys which always trap focus
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp':
case 'Escape':
setShouldHighlightOnHover(false);
e.stopImmediatePropagation();
e.preventDefault();
}
// Navigate options
switch (e.key) {
case 'ArrowDown':
if (isOpen) {
setSelectedOptionIndex(i => {
if (i === undefined) {
return options.length > 1 ? 1 : 0;
} else if (i >= options.length - 1) {
return 0;
} else {
return i + 1;
}
});
} else {
setIsOpen(true);
setSelectedOptionIndex(0);
}
break;
case 'ArrowUp':
if (isOpen) {
setSelectedOptionIndex((i = 0) => {
if (options.length > 1) {
if (i === 0) {
return options.length - 1;
} else {
return i - 1;
}
}
});
} else {
setIsOpen(true);
setSelectedOptionIndex(Math.max(0, options.length - 1));
}
break;
case 'Enter':
if (isOpen) {
if (selectedOptionIndex !== undefined) {
onChange?.(options[selectedOptionIndex].value);
}
setIsOpen(false);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
const refRef = ref.current;
refRef?.addEventListener(LISTENER_KEY_KEYDOWN, listener);
return () => refRef?.removeEventListener(LISTENER_KEY_KEYDOWN, listener);
}, [
isOpen,
options,
selectedOptionIndex,
onChange,
]);
useEffect(() => {
const onMouseMove = () => {
setShouldHighlightOnHover(true);
};
const refRef = ref.current;
refRef?.addEventListener(LISTENER_KEY_MOUSE_MOVE, onMouseMove);
return () =>
refRef?.removeEventListener(LISTENER_KEY_MOUSE_MOVE, onMouseMove);
}, []);
return (
<div ref={ref} className={className}>
<div
tabIndex={tabIndex}
className={clsx(
'cursor-pointer control pl-1.5 py-2',
'flex items-center w-full h-9.5',
'focus:outline-2 -outline-offset-2 focus:outline-blue-600',
'select-none',
Boolean(error) && 'error',
readOnly && 'disabled-select',
)}
onMouseDown={() => setIsOpen(o => !o)}
onFocus={() => setIsOpen(true)}
onBlur={e => {
if (e.relatedTarget && !ref.current?.contains(e.relatedTarget)) {
setIsOpen(false);
}
}}
aria-autocomplete="list"
aria-expanded={isOpen}
aria-haspopup="true"
aria-controls={isOpen ? ARIA_ID_SELECT_OPTIONS : undefined}
role="combobox"
>
{children ?? <div className="flex items-center w-full">
<div className="grow min-w-0">
<SelectMenuOption
value={value}
label={selectedOption?.label}
accessoryStart={selectedOption?.accessoryStart}
/>
</div>
<IconSelectChevron
className={clsx(
'shrink-0',
isOpen && 'rotate-180 transition-transform duration-200',
)}
/>
</div>}
<input id={id} type="hidden" name={name} value={value} />
</div>
<div className="relative">
{isOpen &&
<div
className={clsx(
'component-surface z-1',
'absolute top-3 w-full px-1.5 py-1.5',
'max-h-[12rem] overflow-y-auto flex flex-col',
'shadow-lg dark:shadow-xl',
'animate-fade-in-from-top',
'*:select-none',
)}
>
<MaskedScroll
id={ARIA_ID_SELECT_OPTIONS}
role="listbox"
className="flex flex-col gap-1"
fadeSize={16}
>
{defaultOptionLabel &&
<SelectMenuOption value="" label={defaultOptionLabel} />}
{options.map((option, index) =>
<SelectMenuOption
key={option.value}
value={option.value}
label={option.label}
accessoryStart={option.accessoryStart}
accessoryEnd={option.accessoryEnd}
note={option.note}
isSelected={option.value === value}
isHighlighted={
index === selectedOptionIndex ||
(selectedOptionIndex === undefined && index === 0)}
shouldHighlightOnHover={shouldHighlightOnHover}
onClick={() => {
onChange?.(option.value);
setIsOpen(false);
}}
/>)}
</MaskedScroll>
</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
import clsx from 'clsx/lite';
import { ReactNode, useEffect, useRef } from 'react';
import IconCheck from './icons/IconCheck';
export interface SelectMenuOptionType {
value: string
label: ReactNode
accessoryStart?: ReactNode
accessoryEnd?: ReactNode
note?: ReactNode
}
export default function SelectMenuOption({
label,
accessoryStart,
accessoryEnd,
note,
isSelected,
isHighlighted,
shouldHighlightOnHover,
onClick,
}: {
isSelected?: boolean
isHighlighted?: boolean
shouldHighlightOnHover?: boolean
onClick?: () => void
} & SelectMenuOptionType) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isHighlighted) {
ref.current?.scrollIntoView({ block: 'nearest' });
}
}, [isHighlighted]);
return (
<div
ref={ref}
onClick={onClick}
role="option"
aria-selected={isSelected}
className={clsx(
'flex flex-col',
'px-1.5 py-1 rounded-sm',
'text-lg select-none',
'cursor-pointer',
isHighlighted && 'bg-dim',
shouldHighlightOnHover && 'hover:bg-dim',
onClick && 'active:bg-medium',
)}
>
<div className="flex items-center gap-2.5">
{accessoryStart &&
<div className="shrink-0 text-medium w-5 pl-0.5">
{accessoryStart}
</div>}
<div className="grow min-w-0">
<div className="grow truncate">{label}</div>
{note &&
<div className="text-sm text-dim truncate">
{note}
</div>}
</div>
{(accessoryEnd || isSelected) &&
<div className="shrink-0 text-dim">
{isSelected
? <IconCheck size={13} className="text-main" />
: accessoryEnd}
</div>}
</div>
</div>
);
}

View File

@ -13,8 +13,6 @@ export default function Switcher({
return ( return (
<div className={clsx( <div className={clsx(
'flex divide-x overflow-hidden', 'flex divide-x overflow-hidden',
// Apply offset due to outline strategy
'translate-x-[1px]',
'rounded-[5px]', 'rounded-[5px]',
'divide-medium', 'divide-medium',
type === 'regular' && type === 'regular' &&

View File

@ -6,7 +6,7 @@ import useSWR from 'swr';
import { getDimensionsFromSize } from '@/utility/size'; import { getDimensionsFromSize } from '@/utility/size';
import PhotoMedium from '@/photo/PhotoMedium'; import PhotoMedium from '@/photo/PhotoMedium';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
import clsx from 'clsx'; import clsx from 'clsx/lite';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import { SWR_KEYS } from '@/swr'; import { SWR_KEYS } from '@/swr';

View File

@ -0,0 +1,6 @@
import { IconBaseProps } from 'react-icons';
import { FaCheck } from 'react-icons/fa6';
export default function IconCheck(props: IconBaseProps) {
return <FaCheck {...props} />;
}

View File

@ -0,0 +1,19 @@
import clsx from 'clsx/lite';
import { IconBaseProps } from 'react-icons';
import { FiChevronDown } from 'react-icons/fi';
export default function IconSelectChevron({
className,
...props
}: IconBaseProps) {
return (
<FiChevronDown
{...props}
size={props.size ?? 16}
className={clsx(
'text-extra-dim text-2xl',
className,
)}
/>
);
}

View File

@ -51,8 +51,8 @@ export default function useMaskedScroll({
} }
}, [containerRef, isVertical]); }, [containerRef, isVertical]);
// Conditionally track events
useEffect(() => { useEffect(() => {
// Conditionally track events
const ref = containerRef?.current; const ref = containerRef?.current;
if (ref && updateMaskOnEvents) { if (ref && updateMaskOnEvents) {
ref.onscroll = updateMask; ref.onscroll = updateMask;
@ -62,15 +62,20 @@ export default function useMaskedScroll({
ref.onresize = null; ref.onresize = null;
}; };
} }
}, [containerRef, updateMask, updateMaskOnEvents]);
// Update on mount
useEffect(() => {
updateMask();
}, [updateMask]);
// Update after delay
useEffect(() => {
if (updateMaskAfterDelay) { if (updateMaskAfterDelay) {
// Update after delay
const timeout = setTimeout(updateMask, updateMaskAfterDelay); const timeout = setTimeout(updateMask, updateMaskAfterDelay);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} else {
// Update on mount
updateMask();
} }
}, [containerRef, updateMask, updateMaskOnEvents, updateMaskAfterDelay]); }, [containerRef, updateMask, updateMaskAfterDelay]);
useEffect(() => { useEffect(() => {
const ref = containerRef?.current; const ref = containerRef?.current;

View File

@ -1,20 +1,20 @@
import { AiContent } from './useAiImageQueries'; import { AiContent } from './useAiImageQueries';
import { HiSparkles } from 'react-icons/hi'; import { HiSparkles } from 'react-icons/hi';
import { AI_AUTO_GENERATED_FIELDS_ALL, AiAutoGeneratedField } from '.'; import { AI_AUTO_GENERATED_FIELDS_ALL, AiAutoGeneratedField } from '.';
import { useMemo } from 'react'; import { ComponentProps, useMemo } from 'react';
import LoaderButton from '@/components/primitives/LoaderButton'; import LoaderButton from '@/components/primitives/LoaderButton';
export default function AiButton({ export default function AiButton({
aiContent, aiContent,
requestFields = AI_AUTO_GENERATED_FIELDS_ALL, requestFields = AI_AUTO_GENERATED_FIELDS_ALL,
shouldConfirm, shouldConfirm,
className, ...props
}: { }: {
aiContent: AiContent aiContent: AiContent
requestFields?: AiAutoGeneratedField[] requestFields?: AiAutoGeneratedField[]
shouldConfirm?: boolean shouldConfirm?: boolean
className?: string className?: string
}) { } & ComponentProps<typeof LoaderButton>) {
const isLoading = useMemo(() => const isLoading = useMemo(() =>
(requestFields ?? []).map(field => { (requestFields ?? []).map(field => {
switch (field) { switch (field) {
@ -41,7 +41,6 @@ export default function AiButton({
return ( return (
<LoaderButton <LoaderButton
icon={<HiSparkles size={16} />} icon={<HiSparkles size={16} />}
className={className}
onClick={e => { onClick={e => {
if ( if (
!shouldConfirm || !shouldConfirm ||
@ -53,6 +52,7 @@ export default function AiButton({
} }
}} }}
isLoading={isLoading} isLoading={isLoading}
{...props}
/> />
); );
} }

View File

@ -1,4 +1,4 @@
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { ComponentProps, useEffect, useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { getPhotosNeedingRecipeTitleCountAction } from '../actions'; import { getPhotosNeedingRecipeTitleCountAction } from '../actions';
@ -10,7 +10,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({
film, film,
onMatchResults, onMatchResults,
...props ...props
}: ComponentProps<typeof FieldSetWithStatus> & { }: ComponentProps<typeof FieldsetWithStatus> & {
photoId?: string photoId?: string
recipeTitle?: string recipeTitle?: string
hasRecipeTitleChanged?: boolean hasRecipeTitleChanged?: boolean
@ -40,7 +40,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({
return ( return (
shouldShowFieldSet shouldShowFieldSet
? <FieldSetWithStatus {...{ ? <FieldsetWithStatus {...{
...props, ...props,
label: loading label: loading
? 'Scanning photos for matching recipes ...' ? 'Scanning photos for matching recipes ...'

View File

@ -1,22 +0,0 @@
import { ComponentProps } from 'react';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import IconHidden from '@/components/icons/IconHidden';
export default function FieldsetExclude(props: Omit<
ComponentProps<typeof FieldSetWithStatus>,
'label' | 'icon' | 'type'
>) {
return (
<FieldSetWithStatus
{...props}
label="Exclude from feeds"
type="checkbox"
icon={<IconHidden
size={17}
className="translate-y-[0.5px]"
visible={props.value !== 'true'}
/>}
tooltip="Do not show on homepage views or RSS"
/>
);
}

View File

@ -1,13 +1,13 @@
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
export default function FieldsetFavs(props: Omit< export default function FieldsetFavs(props: Omit<
ComponentProps<typeof FieldSetWithStatus>, ComponentProps<typeof FieldsetWithStatus>,
'label' | 'icon' | 'type' 'label' | 'icon' | 'type'
>) { >) {
return ( return (
<FieldSetWithStatus <FieldsetWithStatus
{...props} {...props}
label="Favorite" label="Favorite"
type="checkbox" type="checkbox"

View File

@ -1,22 +0,0 @@
import { ComponentProps } from 'react';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import IconLock from '@/components/icons/IconLock';
export default function FieldsetPrivate(props: Omit<
ComponentProps<typeof FieldSetWithStatus>,
'label' | 'icon' | 'type'
>) {
return (
<FieldSetWithStatus
{...props}
label="Private"
type="checkbox"
icon={<IconLock
size={15}
open={props.value !== 'true'}
narrow
/>}
tooltip="Visible only to authenticated admin"
/>
);
}

View File

@ -19,7 +19,7 @@ import {
getFormErrors, getFormErrors,
isFormValid, isFormValid,
} from '.'; } from '.';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { createPhotoAction, updatePhotoAction } from '../actions'; import { createPhotoAction, updatePhotoAction } from '../actions';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link'; import Link from 'next/link';
@ -45,10 +45,10 @@ import { convertFilmsForForm, Films } from '@/film';
import { isMakeFujifilm } from '@/platforms/fujifilm'; import { isMakeFujifilm } from '@/platforms/fujifilm';
import PhotoFilmIcon from '@/film/PhotoFilmIcon'; import PhotoFilmIcon from '@/film/PhotoFilmIcon';
import FieldsetFavs from './FieldsetFavs'; import FieldsetFavs from './FieldsetFavs';
import FieldsetPrivate from './FieldsetPrivate';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import IconAddUpload from '@/components/icons/IconAddUpload'; import IconAddUpload from '@/components/icons/IconAddUpload';
import FieldsetExclude from './FieldsetExclude'; import { didVisibilityChange } from '../visibility';
import FieldsetVisibility from '../visibility/FieldsetVisibility';
const THUMBNAIL_SIZE = 300; const THUMBNAIL_SIZE = 300;
@ -185,16 +185,6 @@ export default function PhotoForm({
onTextContentChange?.(formHasTextContent(formData)); onTextContentChange?.(formHasTextContent(formData));
}, [onTextContentChange, formData]); }, [onTextContentChange, formData]);
useEffect(() => {
if (formData.hidden === 'true') {
setFormData(data => ({
...data,
excludeFromFeeds: 'false',
favorite: 'false',
}));
}
}, [formData.hidden]);
const isFieldGeneratingAi = (key: keyof PhotoFormData) => { const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
switch (key) { switch (key) {
case 'title': case 'title':
@ -215,6 +205,7 @@ export default function PhotoForm({
switch (key) { switch (key) {
case 'title': case 'title':
return <AiButton return <AiButton
tabIndex={-1}
aiContent={aiContent} aiContent={aiContent}
requestFields={['title']} requestFields={['title']}
shouldConfirm={Boolean(formData.title)} shouldConfirm={Boolean(formData.title)}
@ -222,6 +213,7 @@ export default function PhotoForm({
/>; />;
case 'caption': case 'caption':
return <AiButton return <AiButton
tabIndex={-1}
aiContent={aiContent} aiContent={aiContent}
requestFields={['caption']} requestFields={['caption']}
shouldConfirm={Boolean(formData.caption)} shouldConfirm={Boolean(formData.caption)}
@ -229,6 +221,7 @@ export default function PhotoForm({
/>; />;
case 'tags': case 'tags':
return <AiButton return <AiButton
tabIndex={-1}
aiContent={aiContent} aiContent={aiContent}
requestFields={['tags']} requestFields={['tags']}
shouldConfirm={Boolean(formData.tags)} shouldConfirm={Boolean(formData.tags)}
@ -236,6 +229,7 @@ export default function PhotoForm({
/>; />;
case 'semanticDescription': case 'semanticDescription':
return <AiButton return <AiButton
tabIndex={-1}
aiContent={aiContent} aiContent={aiContent}
requestFields={['semantic']} requestFields={['semantic']}
shouldConfirm={Boolean(formData.semanticDescription)} shouldConfirm={Boolean(formData.semanticDescription)}
@ -272,13 +266,6 @@ export default function PhotoForm({
} }
}; };
const isFieldReadOnly = (key: FormFields) => {
return formData.hidden === 'true' && (
key === 'excludeFromFeeds' ||
key === 'favorite'
);
};
const onMatchResults = useCallback((didFindMatchingPhotos: boolean) => { const onMatchResults = useCallback((didFindMatchingPhotos: boolean) => {
setFormData(data => ({ setFormData(data => ({
...data, ...data,
@ -380,7 +367,7 @@ export default function PhotoForm({
staticValue, staticValue,
}]) => { }]) => {
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) { if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = { const fieldProps: ComponentProps<typeof FieldsetWithStatus> = {
id: key, id: key,
label: label + ( label: label + (
key === 'blurData' && shouldDebugImageFallbacks key === 'blurData' && shouldDebugImageFallbacks
@ -421,7 +408,7 @@ export default function PhotoForm({
tagOptionsLimit, tagOptionsLimit,
tagOptionsLimitValidationMessage, tagOptionsLimitValidationMessage,
required, required,
readOnly: readOnly || isFieldReadOnly(key), readOnly,
spellCheck, spellCheck,
capitalize, capitalize,
placeholder: loadingMessage && !formData[key] placeholder: loadingMessage && !formData[key]
@ -437,18 +424,19 @@ export default function PhotoForm({
switch (key) { switch (key) {
case 'film': case 'film':
return <FieldSetWithStatus return <FieldsetWithStatus
key={key} key={key}
{...fieldProps}
tagOptionsDefaultIcon={<span tagOptionsDefaultIcon={<span
className="w-4 overflow-hidden" className="w-4 overflow-hidden"
> >
<PhotoFilmIcon /> <PhotoFilmIcon />
</span>} </span>}
{...fieldProps}
/>; />;
case 'applyRecipeTitleGlobally': case 'applyRecipeTitleGlobally':
return <ApplyRecipeTitleGloballyCheckbox return <ApplyRecipeTitleGloballyCheckbox
key={key} key={key}
{...fieldProps}
photoId={initialPhotoForm.id} photoId={initialPhotoForm.id}
recipeTitle={formData.recipeTitle} recipeTitle={formData.recipeTitle}
hasRecipeTitleChanged={ hasRecipeTitleChanged={
@ -456,25 +444,25 @@ export default function PhotoForm({
recipeData={formData.recipeData} recipeData={formData.recipeData}
film={formData.film} film={formData.film}
onMatchResults={onMatchResults} onMatchResults={onMatchResults}
/>;
case 'visibility':
return <FieldsetVisibility
key={key}
{...fieldProps} {...fieldProps}
formData={formData}
setFormData={setFormData}
isModified={didVisibilityChange(
initialPhotoForm,
formData,
)}
/>; />;
case 'favorite': case 'favorite':
return <FieldsetFavs return <FieldsetFavs
key={key} key={key}
{...fieldProps} {...fieldProps}
/>; />;
case 'excludeFromFeeds':
return <FieldsetExclude
key={key}
{...fieldProps}
/>;
case 'hidden':
return <FieldsetPrivate
key={key}
{...fieldProps}
/>;
default: default:
return <FieldSetWithStatus return <FieldsetWithStatus
key={key} key={key}
{...fieldProps} {...fieldProps}
/>; />;

View File

@ -13,8 +13,10 @@ import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { SelectMenuOptionType } from '@/components/SelectMenuOption';
type VirtualFields = type VirtualFields =
'visibility' |
'favorite' | 'favorite' |
'applyRecipeTitleGlobally' | 'applyRecipeTitleGlobally' |
'shouldStripGpsData'; 'shouldStripGpsData';
@ -58,7 +60,7 @@ export type FormMeta = {
) => boolean ) => boolean
loadingMessage?: string loadingMessage?: string
type?: FieldSetType type?: FieldSetType
selectOptions?: { value: string, label: string }[] selectOptions?: SelectMenuOptionType[]
selectOptionsDefaultLabel?: string selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[] tagOptions?: AnnotatedTag[]
tagOptionsLimit?: number tagOptionsLimit?: number
@ -181,9 +183,14 @@ const FORM_METADATA = (
validate: validationMessageNaivePostgresDateString, validate: validationMessageNaivePostgresDateString,
}, },
priorityOrder: { label: 'priority order' }, priorityOrder: { label: 'priority order' },
excludeFromFeeds: { label: 'exclude from feeds', type: 'hidden' },
hidden: { label: 'hidden', type: 'hidden' },
visibility: {
type: 'text',
label: 'visibility',
excludeFromInsert: true,
},
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true }, favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
excludeFromFeeds: { label: 'exclude from feeds', type: 'checkbox' },
hidden: { label: 'hidden', type: 'checkbox' },
shouldStripGpsData: { shouldStripGpsData: {
label: 'strip gps data', label: 'strip gps data',
type: 'hidden', type: 'hidden',

View File

@ -0,0 +1,33 @@
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { ComponentProps, Dispatch, SetStateAction } from 'react';
import {
getVisibilityValue,
updateFormDataWithVisibility,
VISIBILITY_OPTIONS,
VisibilityValue,
} from '.';
import { PhotoFormData } from '../form';
export default function FieldsetVisibility({
formData,
setFormData,
...props
}: {
label?: string
formData: Partial<PhotoFormData>
setFormData: Dispatch<SetStateAction<Partial<PhotoFormData>>>
} & Omit<ComponentProps<typeof FieldsetWithStatus>, 'label' | 'value'>) {
return (
<FieldsetWithStatus
label="Visibility"
{...props}
selectOptions={VISIBILITY_OPTIONS}
value={getVisibilityValue(formData)}
onChange={value => setFormData(data =>
updateFormDataWithVisibility(
data,
value as VisibilityValue,
))}
/>
);
}

View File

@ -0,0 +1,21 @@
import IconLock from '@/components/icons/IconLock';
import { Photo } from '..';
import IconHidden from '@/components/icons/IconHidden';
import { EXCLUDE_DESCRIPTION, PRIVATE_DESCRIPTION } from '.';
import Tooltip from '@/components/Tooltip';
export default function PhotoVisibilityIcon({
photo,
}: {
photo: Photo
}) {
return photo.hidden
? <Tooltip content={PRIVATE_DESCRIPTION}>
<IconLock size={13} />
</Tooltip>
: photo.excludeFromFeeds
? <Tooltip content={EXCLUDE_DESCRIPTION}>
<IconHidden size={16} className="translate-y-[0.5px]" />
</Tooltip>
: null;
}

View File

@ -0,0 +1,60 @@
import IconHidden from '@/components/icons/IconHidden';
import { PhotoFormData } from '../form';
import IconLock from '@/components/icons/IconLock';
import { SelectMenuOptionType } from '@/components/SelectMenuOption';
export type VisibilityValue = 'default' | 'exclude' | 'private';
export const EXCLUDE_DESCRIPTION =
'Excluded from homepage views, rss.xml, etc.';
export const PRIVATE_DESCRIPTION =
'Visible only to admins';
export const VISIBILITY_OPTIONS: SelectMenuOptionType[] = [
{
value: 'default',
accessoryStart: <IconHidden size={17} visible />,
label: 'Default',
note: 'Viewable everywhere',
},
{
value: 'exclude',
accessoryStart: <IconHidden size={17} />,
label: 'Hide from feeds',
note: EXCLUDE_DESCRIPTION,
},
{
value: 'private',
accessoryStart: <IconLock size={14} />,
label: 'Private',
note: PRIVATE_DESCRIPTION,
},
];
export const getVisibilityValue = (
formData: Partial<PhotoFormData>,
): VisibilityValue =>
formData.hidden === 'true'
? 'private'
: formData.excludeFromFeeds === 'true'
? 'exclude'
: 'default';
export const updateFormDataWithVisibility = (
formData: Partial<PhotoFormData>,
value: VisibilityValue,
): Partial<PhotoFormData> => {
return {
...formData,
...value === 'private'
? { hidden: 'true', excludeFromFeeds: 'false' }
: value === 'exclude'
? { hidden: 'false', excludeFromFeeds: 'true' }
: { hidden: 'false', excludeFromFeeds: 'false' },
};
};
export const didVisibilityChange = (
original: Partial<PhotoFormData>,
current: Partial<PhotoFormData>,
) => getVisibilityValue(original) !== getVisibilityValue(current);

View File

@ -289,7 +289,7 @@ html {
} }
.error { .error {
@apply @apply
border-red-500 dark:border-red-400 outline-2 outline-red-500! dark:outline-red-400!
} }
button, .button { button, .button {
@apply @apply