Combine visibility setting into single dropdown (#281)
This commit is contained in:
parent
468f7fe3d0
commit
9cea328386
@ -3,7 +3,7 @@
|
||||
import PhotoCamera from '@/camera/PhotoCamera';
|
||||
import Badge from '@/components/Badge';
|
||||
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import EntityLink from '@/components/entity/EntityLink';
|
||||
import LabeledIcon from '@/components/primitives/LabeledIcon';
|
||||
@ -45,13 +45,13 @@ export default function ComponentsPage() {
|
||||
'flex gap-1',
|
||||
'*:inline-flex *:gap-1 [&_input]:-translate-y-0.5',
|
||||
)}>
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
label="Grid"
|
||||
type="checkbox"
|
||||
value={shouldShowBaselineGrid ? 'true' : 'false'}
|
||||
onChange={e => setShouldShowBaselineGrid?.(e === 'true')}
|
||||
/>
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
label="Components"
|
||||
type="checkbox"
|
||||
value={debugComponents ? 'true' : 'false'}
|
||||
|
||||
@ -1,20 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import PhotoTagFieldset from '@/admin/PhotoTagFieldset';
|
||||
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 clsx from 'clsx/lite';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const [value, setValue] = useState('visible');
|
||||
return (
|
||||
<AppGrid
|
||||
contentMain={<div className={clsx(
|
||||
'flex gap-0.5',
|
||||
'*:inline-flex *:bg-medium',
|
||||
)}>
|
||||
<StatusIcon type="checked" />
|
||||
<StatusIcon type="missing" />
|
||||
<StatusIcon type="warning" />
|
||||
<StatusIcon type="optional" />
|
||||
contentMain={<div className="flex flex-col gap-4">
|
||||
<div className={clsx(
|
||||
'flex gap-0.5',
|
||||
'*:inline-flex *:bg-medium',
|
||||
)}>
|
||||
<StatusIcon type="checked" />
|
||||
<StatusIcon type="missing" />
|
||||
<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>}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import ErrorNote from '@/components/ErrorNote';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import Container from '@/components/Container';
|
||||
import { addUploadsAction } from '@/photo/actions';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import sleep from '@/utility/sleep';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
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 ProgressButton from '@/components/primitives/ProgressButton';
|
||||
import { UrlAddStatus } from './AdminUploadsClient';
|
||||
@ -22,9 +22,9 @@ import DeleteUploadButton from './DeleteUploadButton';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { pluralize } from '@/utility/string';
|
||||
import FieldsetFavs from '@/photo/form/FieldsetFavs';
|
||||
import FieldsetPrivate from '@/photo/form/FieldsetPrivate';
|
||||
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;
|
||||
|
||||
@ -52,11 +52,8 @@ export default function AdminBatchUploadActions({
|
||||
const { updateAdminData } = useAppState();
|
||||
|
||||
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 [formData, setFormData] = useState<Partial<PhotoFormData>>({});
|
||||
|
||||
const [buttonText, setButtonText] = useState('Add All Uploads');
|
||||
const [actionErrorMessage, setActionErrorMessage] = useState('');
|
||||
@ -71,6 +68,7 @@ export default function AdminBatchUploadActions({
|
||||
titles: string[],
|
||||
isFinalBatch: boolean,
|
||||
) => {
|
||||
const { tags, favorite, excludeFromFeeds, hidden } = formData;
|
||||
try {
|
||||
const stream = await addUploadsAction({
|
||||
uploadUrls: urls,
|
||||
@ -126,13 +124,6 @@ export default function AdminBatchUploadActions({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hidden === 'true') {
|
||||
setFavorite('false');
|
||||
setExcludeFromFeeds('false');
|
||||
}
|
||||
}, [hidden]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionErrorMessage &&
|
||||
@ -145,7 +136,7 @@ export default function AdminBatchUploadActions({
|
||||
? `Apply to ${pluralize(uploadUrls.length, 'upload')}`
|
||||
: `Found ${pluralize(uploadUrls.length, 'upload')}`}
|
||||
</div>
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
label="Apply to All"
|
||||
type="checkbox"
|
||||
value={showBulkSettings ? 'true' : 'false'}
|
||||
@ -157,30 +148,25 @@ export default function AdminBatchUploadActions({
|
||||
<div className="space-y-4 mb-6">
|
||||
<PhotoTagFieldset
|
||||
label="Tags"
|
||||
tags={tags}
|
||||
tags={formData.tags ?? ''}
|
||||
tagOptions={uniqueTags}
|
||||
onChange={setTags}
|
||||
onChange={tags => setFormData(data => ({ ...data, tags }))}
|
||||
onError={setTagErrorMessage}
|
||||
readOnly={isAdding}
|
||||
className="relative z-10"
|
||||
/>
|
||||
<div className="flex max-sm:flex-col gap-x-8 gap-y-4">
|
||||
<FieldsetFavs
|
||||
value={favorite}
|
||||
onChange={setFavorite}
|
||||
readOnly={isAdding || hidden === 'true'}
|
||||
/>
|
||||
<FieldsetExclude
|
||||
value={excludeFromFeeds}
|
||||
onChange={setExcludeFromFeeds}
|
||||
readOnly={isAdding || hidden === 'true'}
|
||||
/>
|
||||
<FieldsetPrivate
|
||||
value={hidden}
|
||||
onChange={setHidden}
|
||||
readOnly={isAdding}
|
||||
/>
|
||||
</div>
|
||||
<FieldsetVisibility
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
readOnly={isAdding}
|
||||
/>
|
||||
<FieldsetFavs
|
||||
className="my-6"
|
||||
value={formData.favorite ?? 'false'}
|
||||
onChange={favorite =>
|
||||
setFormData(data => ({ ...data, favorite }))}
|
||||
readOnly={isAdding}
|
||||
/>
|
||||
</div>}
|
||||
<div className="flex flex-col sm:flex-row-reverse gap-2">
|
||||
<ProgressButton
|
||||
|
||||
@ -24,7 +24,7 @@ const ADMIN_INFO_PAGE_WITHOUT_INSIGHTS = [{
|
||||
path: PATH_ADMIN_CONFIGURATION,
|
||||
}] as typeof ADMIN_INFO_PAGES;
|
||||
|
||||
export default function AdminInfoPage({
|
||||
export default function AdminInfoNav({
|
||||
includeInsights,
|
||||
}: {
|
||||
includeInsights: boolean
|
||||
|
||||
@ -14,10 +14,9 @@ import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||
import PhotoSyncButton from './PhotoSyncButton';
|
||||
import DeletePhotoButton from './DeletePhotoButton';
|
||||
import { Timezone } from '@/utility/timezone';
|
||||
import IconHidden from '@/components/icons/IconHidden';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync';
|
||||
import IconLock from '@/components/icons/IconLock';
|
||||
import PhotoVisibilityIcon from '@/photo/visibility/PhotoVisibilityIcon';
|
||||
|
||||
export default function AdminPhotosTable({
|
||||
photos,
|
||||
@ -77,20 +76,9 @@ export default function AdminPhotosTable({
|
||||
<span className="truncate">
|
||||
{titleForPhoto(photo, false)}
|
||||
</span>
|
||||
{photo.excludeFromFeeds && !photo.hidden &&
|
||||
<span>
|
||||
<IconHidden
|
||||
className="inline translate-y-[-1px]"
|
||||
size={16}
|
||||
/>
|
||||
</span>}
|
||||
{photo.hidden &&
|
||||
<span>
|
||||
<IconLock
|
||||
size={13}
|
||||
className="inline translate-y-[-1.5px]"
|
||||
/>
|
||||
</span>}
|
||||
<span className="inline-flex items-center">
|
||||
<PhotoVisibilityIcon photo={photo} />
|
||||
</span>
|
||||
</span>
|
||||
{photo.priorityOrder !== null &&
|
||||
<span className={clsx(
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import Link from 'next/link';
|
||||
import { PATH_ADMIN_RECIPES } from '@/app/paths';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
|
||||
import { parameterize } from '@/utility/string';
|
||||
@ -34,7 +34,7 @@ export default function AdminRecipeForm({
|
||||
action={renamePhotoRecipeGloballyAction}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
label="New Recipe Name"
|
||||
value={updatedRecipeRaw}
|
||||
onChange={setUpdatedRecipeRaw}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import Link from 'next/link';
|
||||
import { PATH_ADMIN_TAGS } from '@/app/paths';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { renamePhotoTagGloballyAction } from '@/photo/actions';
|
||||
import { parameterize } from '@/utility/string';
|
||||
@ -34,7 +34,7 @@ export default function AdminTagForm({
|
||||
action={renamePhotoTagGloballyAction}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
label="New Tag Name"
|
||||
value={updatedTagRaw}
|
||||
onChange={setUpdatedTagRaw}
|
||||
|
||||
@ -12,7 +12,7 @@ import { pathForAdminUploadUrl } from '@/app/paths';
|
||||
import DeleteUploadButton from './DeleteUploadButton';
|
||||
import { Dispatch, SetStateAction, useEffect, useRef } from 'react';
|
||||
import { isElementEntirelyInViewport } from '@/utility/dom';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import EditButton from './EditButton';
|
||||
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 grow gap-2">
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
label="Title"
|
||||
value={draftTitle}
|
||||
onChange={titleUpdated =>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
|
||||
import { ComponentProps, useEffect, useRef, useState } from 'react';
|
||||
@ -12,7 +12,7 @@ export default function PhotoTagFieldset(props: {
|
||||
onError?: (error: string) => void
|
||||
openOnLoad?: boolean
|
||||
} & Partial<Omit<
|
||||
ComponentProps<typeof FieldSetWithStatus>,
|
||||
ComponentProps<typeof FieldsetWithStatus>,
|
||||
'tagOptions'
|
||||
>>) {
|
||||
const {
|
||||
@ -41,7 +41,7 @@ export default function PhotoTagFieldset(props: {
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
{...rest}
|
||||
inputRef={ref}
|
||||
label="Tags"
|
||||
|
||||
@ -118,7 +118,13 @@ export default function AppViewSwitcher({
|
||||
|
||||
return (
|
||||
<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 ? renderItemFull : renderItemGrid}
|
||||
{/* Show spinner if admin is suspected to be logged in */}
|
||||
|
||||
@ -23,7 +23,10 @@ export default function ThemeSwitcher () {
|
||||
}
|
||||
|
||||
return (
|
||||
<Switcher>
|
||||
<Switcher
|
||||
// Apply offset due to outline strategy
|
||||
className="translate-x-[-1px]"
|
||||
>
|
||||
<SwitcherItem
|
||||
icon={<BiDesktop size={16} />}
|
||||
onClick={() => setTheme('system')}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import Container from '@/components/Container';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import {
|
||||
@ -92,7 +92,7 @@ export default function SignInForm({
|
||||
{appText.auth.invalidEmailPassword}
|
||||
</ErrorNote>}
|
||||
<div className="space-y-4 w-full">
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
id="email"
|
||||
inputRef={emailRef}
|
||||
label={appText.auth.email}
|
||||
@ -100,7 +100,7 @@ export default function SignInForm({
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
/>
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
id="password"
|
||||
label={appText.auth.password}
|
||||
type="password"
|
||||
|
||||
@ -48,7 +48,6 @@ import { signOutAction } from '@/auth/actions';
|
||||
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
|
||||
import PhotoDate from '@/photo/PhotoDate';
|
||||
import PhotoSmall from '@/photo/PhotoSmall';
|
||||
import { FaCheck } from 'react-icons/fa6';
|
||||
import {
|
||||
addPrivateToTags,
|
||||
formatTag,
|
||||
@ -90,6 +89,7 @@ import IconRecents from '@/components/icons/IconRecents';
|
||||
import { CgFileDocument } from 'react-icons/cg';
|
||||
import { FaRegUserCircle } from 'react-icons/fa';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import IconCheck from '@/components/icons/IconCheck';
|
||||
|
||||
const DIALOG_TITLE = 'Global Command-K Menu';
|
||||
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
|
||||
@ -123,7 +123,7 @@ const renderToggle = (
|
||||
): CommandKItem => ({
|
||||
label: `Toggle ${label}`,
|
||||
action: () => onToggle?.(prev => !prev),
|
||||
annotation: isEnabled ? <FaCheck size={12} /> : undefined,
|
||||
annotation: isEnabled ? <IconCheck size={12} /> : undefined,
|
||||
});
|
||||
|
||||
export default function CommandKClient({
|
||||
|
||||
@ -6,13 +6,14 @@ import Spinner from './Spinner';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { FieldSetType, AnnotatedTag } from '@/photo/form';
|
||||
import TagInput from './TagInput';
|
||||
import { FiChevronDown } from 'react-icons/fi';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import Checkbox from './Checkbox';
|
||||
import ResponsiveText from './primitives/ResponsiveText';
|
||||
import Tooltip from './Tooltip';
|
||||
import { SelectMenuOptionType } from './SelectMenuOption';
|
||||
import SelectMenu from './SelectMenu';
|
||||
|
||||
export default function FieldSetWithStatus({
|
||||
export default function FieldsetWithStatus({
|
||||
id: _id,
|
||||
label,
|
||||
icon,
|
||||
@ -53,7 +54,7 @@ export default function FieldSetWithStatus({
|
||||
isModified?: boolean
|
||||
onChange?: (value: string) => void
|
||||
className?: string
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
selectOptions?: SelectMenuOptionType[]
|
||||
selectOptionsDefaultLabel?: string
|
||||
tagOptions?: AnnotatedTag[]
|
||||
tagOptionsLimit?: number
|
||||
@ -178,40 +179,18 @@ export default function FieldSetWithStatus({
|
||||
</label>}
|
||||
<div className="flex gap-2">
|
||||
{selectOptions
|
||||
? <div className="relative w-full">
|
||||
<select
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
className={clsx(
|
||||
'w-full',
|
||||
clsx(Boolean(error) && 'error'),
|
||||
// Use special class because `select` can't be readonly
|
||||
readOnly || pending && 'disabled-select',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
? <SelectMenu
|
||||
id={id}
|
||||
name={id}
|
||||
tabIndex={tabIndex}
|
||||
className="w-full"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={selectOptions}
|
||||
defaultOptionLabel={selectOptionsDefaultLabel}
|
||||
error={error}
|
||||
readOnly={readOnly || pending || loading}
|
||||
/>
|
||||
: tagOptions
|
||||
? <TagInput
|
||||
id={id}
|
||||
|
||||
222
src/components/SelectMenu.tsx
Normal file
222
src/components/SelectMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/SelectMenuOption.tsx
Normal file
73
src/components/SelectMenuOption.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -13,8 +13,6 @@ export default function Switcher({
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex divide-x overflow-hidden',
|
||||
// Apply offset due to outline strategy
|
||||
'translate-x-[1px]',
|
||||
'rounded-[5px]',
|
||||
'divide-medium',
|
||||
type === 'regular' &&
|
||||
|
||||
@ -6,7 +6,7 @@ import useSWR from 'swr';
|
||||
import { getDimensionsFromSize } from '@/utility/size';
|
||||
import PhotoMedium from '@/photo/PhotoMedium';
|
||||
import Spinner from '../Spinner';
|
||||
import clsx from 'clsx';
|
||||
import clsx from 'clsx/lite';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { SWR_KEYS } from '@/swr';
|
||||
|
||||
|
||||
6
src/components/icons/IconCheck.tsx
Normal file
6
src/components/icons/IconCheck.tsx
Normal 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} />;
|
||||
}
|
||||
19
src/components/icons/IconSelectChevron.tsx
Normal file
19
src/components/icons/IconSelectChevron.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -51,8 +51,8 @@ export default function useMaskedScroll({
|
||||
}
|
||||
}, [containerRef, isVertical]);
|
||||
|
||||
// Conditionally track events
|
||||
useEffect(() => {
|
||||
// Conditionally track events
|
||||
const ref = containerRef?.current;
|
||||
if (ref && updateMaskOnEvents) {
|
||||
ref.onscroll = updateMask;
|
||||
@ -62,15 +62,20 @@ export default function useMaskedScroll({
|
||||
ref.onresize = null;
|
||||
};
|
||||
}
|
||||
}, [containerRef, updateMask, updateMaskOnEvents]);
|
||||
|
||||
// Update on mount
|
||||
useEffect(() => {
|
||||
updateMask();
|
||||
}, [updateMask]);
|
||||
|
||||
// Update after delay
|
||||
useEffect(() => {
|
||||
if (updateMaskAfterDelay) {
|
||||
// Update after delay
|
||||
const timeout = setTimeout(updateMask, updateMaskAfterDelay);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
// Update on mount
|
||||
updateMask();
|
||||
}
|
||||
}, [containerRef, updateMask, updateMaskOnEvents, updateMaskAfterDelay]);
|
||||
}, [containerRef, updateMask, updateMaskAfterDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
const ref = containerRef?.current;
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import { AiContent } from './useAiImageQueries';
|
||||
import { HiSparkles } from 'react-icons/hi';
|
||||
import { AI_AUTO_GENERATED_FIELDS_ALL, AiAutoGeneratedField } from '.';
|
||||
import { useMemo } from 'react';
|
||||
import { ComponentProps, useMemo } from 'react';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
|
||||
export default function AiButton({
|
||||
aiContent,
|
||||
requestFields = AI_AUTO_GENERATED_FIELDS_ALL,
|
||||
shouldConfirm,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
aiContent: AiContent
|
||||
requestFields?: AiAutoGeneratedField[]
|
||||
shouldConfirm?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
} & ComponentProps<typeof LoaderButton>) {
|
||||
const isLoading = useMemo(() =>
|
||||
(requestFields ?? []).map(field => {
|
||||
switch (field) {
|
||||
@ -41,7 +41,6 @@ export default function AiButton({
|
||||
return (
|
||||
<LoaderButton
|
||||
icon={<HiSparkles size={16} />}
|
||||
className={className}
|
||||
onClick={e => {
|
||||
if (
|
||||
!shouldConfirm ||
|
||||
@ -53,6 +52,7 @@ export default function AiButton({
|
||||
}
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { getPhotosNeedingRecipeTitleCountAction } from '../actions';
|
||||
|
||||
@ -10,7 +10,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({
|
||||
film,
|
||||
onMatchResults,
|
||||
...props
|
||||
}: ComponentProps<typeof FieldSetWithStatus> & {
|
||||
}: ComponentProps<typeof FieldsetWithStatus> & {
|
||||
photoId?: string
|
||||
recipeTitle?: string
|
||||
hasRecipeTitleChanged?: boolean
|
||||
@ -40,7 +40,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({
|
||||
|
||||
return (
|
||||
shouldShowFieldSet
|
||||
? <FieldSetWithStatus {...{
|
||||
? <FieldsetWithStatus {...{
|
||||
...props,
|
||||
label: loading
|
||||
? 'Scanning photos for matching recipes ...'
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import IconFavs from '@/components/icons/IconFavs';
|
||||
|
||||
export default function FieldsetFavs(props: Omit<
|
||||
ComponentProps<typeof FieldSetWithStatus>,
|
||||
ComponentProps<typeof FieldsetWithStatus>,
|
||||
'label' | 'icon' | 'type'
|
||||
>) {
|
||||
return (
|
||||
<FieldSetWithStatus
|
||||
<FieldsetWithStatus
|
||||
{...props}
|
||||
label="Favorite"
|
||||
type="checkbox"
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -19,7 +19,7 @@ import {
|
||||
getFormErrors,
|
||||
isFormValid,
|
||||
} from '.';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { createPhotoAction, updatePhotoAction } from '../actions';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import Link from 'next/link';
|
||||
@ -45,10 +45,10 @@ import { convertFilmsForForm, Films } from '@/film';
|
||||
import { isMakeFujifilm } from '@/platforms/fujifilm';
|
||||
import PhotoFilmIcon from '@/film/PhotoFilmIcon';
|
||||
import FieldsetFavs from './FieldsetFavs';
|
||||
import FieldsetPrivate from './FieldsetPrivate';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import IconAddUpload from '@/components/icons/IconAddUpload';
|
||||
import FieldsetExclude from './FieldsetExclude';
|
||||
import { didVisibilityChange } from '../visibility';
|
||||
import FieldsetVisibility from '../visibility/FieldsetVisibility';
|
||||
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
|
||||
@ -185,16 +185,6 @@ export default function PhotoForm({
|
||||
onTextContentChange?.(formHasTextContent(formData));
|
||||
}, [onTextContentChange, formData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.hidden === 'true') {
|
||||
setFormData(data => ({
|
||||
...data,
|
||||
excludeFromFeeds: 'false',
|
||||
favorite: 'false',
|
||||
}));
|
||||
}
|
||||
}, [formData.hidden]);
|
||||
|
||||
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
|
||||
switch (key) {
|
||||
case 'title':
|
||||
@ -215,6 +205,7 @@ export default function PhotoForm({
|
||||
switch (key) {
|
||||
case 'title':
|
||||
return <AiButton
|
||||
tabIndex={-1}
|
||||
aiContent={aiContent}
|
||||
requestFields={['title']}
|
||||
shouldConfirm={Boolean(formData.title)}
|
||||
@ -222,6 +213,7 @@ export default function PhotoForm({
|
||||
/>;
|
||||
case 'caption':
|
||||
return <AiButton
|
||||
tabIndex={-1}
|
||||
aiContent={aiContent}
|
||||
requestFields={['caption']}
|
||||
shouldConfirm={Boolean(formData.caption)}
|
||||
@ -229,6 +221,7 @@ export default function PhotoForm({
|
||||
/>;
|
||||
case 'tags':
|
||||
return <AiButton
|
||||
tabIndex={-1}
|
||||
aiContent={aiContent}
|
||||
requestFields={['tags']}
|
||||
shouldConfirm={Boolean(formData.tags)}
|
||||
@ -236,6 +229,7 @@ export default function PhotoForm({
|
||||
/>;
|
||||
case 'semanticDescription':
|
||||
return <AiButton
|
||||
tabIndex={-1}
|
||||
aiContent={aiContent}
|
||||
requestFields={['semantic']}
|
||||
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) => {
|
||||
setFormData(data => ({
|
||||
...data,
|
||||
@ -380,7 +367,7 @@ export default function PhotoForm({
|
||||
staticValue,
|
||||
}]) => {
|
||||
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
|
||||
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
|
||||
const fieldProps: ComponentProps<typeof FieldsetWithStatus> = {
|
||||
id: key,
|
||||
label: label + (
|
||||
key === 'blurData' && shouldDebugImageFallbacks
|
||||
@ -421,7 +408,7 @@ export default function PhotoForm({
|
||||
tagOptionsLimit,
|
||||
tagOptionsLimitValidationMessage,
|
||||
required,
|
||||
readOnly: readOnly || isFieldReadOnly(key),
|
||||
readOnly,
|
||||
spellCheck,
|
||||
capitalize,
|
||||
placeholder: loadingMessage && !formData[key]
|
||||
@ -437,18 +424,19 @@ export default function PhotoForm({
|
||||
|
||||
switch (key) {
|
||||
case 'film':
|
||||
return <FieldSetWithStatus
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
tagOptionsDefaultIcon={<span
|
||||
className="w-4 overflow-hidden"
|
||||
>
|
||||
<PhotoFilmIcon />
|
||||
</span>}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
case 'applyRecipeTitleGlobally':
|
||||
return <ApplyRecipeTitleGloballyCheckbox
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
photoId={initialPhotoForm.id}
|
||||
recipeTitle={formData.recipeTitle}
|
||||
hasRecipeTitleChanged={
|
||||
@ -456,25 +444,25 @@ export default function PhotoForm({
|
||||
recipeData={formData.recipeData}
|
||||
film={formData.film}
|
||||
onMatchResults={onMatchResults}
|
||||
/>;
|
||||
case 'visibility':
|
||||
return <FieldsetVisibility
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
isModified={didVisibilityChange(
|
||||
initialPhotoForm,
|
||||
formData,
|
||||
)}
|
||||
/>;
|
||||
case 'favorite':
|
||||
return <FieldsetFavs
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
case 'excludeFromFeeds':
|
||||
return <FieldsetExclude
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
case 'hidden':
|
||||
return <FieldsetPrivate
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
default:
|
||||
return <FieldSetWithStatus
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
|
||||
@ -13,8 +13,10 @@ import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
import { ReactNode } from 'react';
|
||||
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import { SelectMenuOptionType } from '@/components/SelectMenuOption';
|
||||
|
||||
type VirtualFields =
|
||||
'visibility' |
|
||||
'favorite' |
|
||||
'applyRecipeTitleGlobally' |
|
||||
'shouldStripGpsData';
|
||||
@ -58,7 +60,7 @@ export type FormMeta = {
|
||||
) => boolean
|
||||
loadingMessage?: string
|
||||
type?: FieldSetType
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
selectOptions?: SelectMenuOptionType[]
|
||||
selectOptionsDefaultLabel?: string
|
||||
tagOptions?: AnnotatedTag[]
|
||||
tagOptionsLimit?: number
|
||||
@ -181,9 +183,14 @@ const FORM_METADATA = (
|
||||
validate: validationMessageNaivePostgresDateString,
|
||||
},
|
||||
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 },
|
||||
excludeFromFeeds: { label: 'exclude from feeds', type: 'checkbox' },
|
||||
hidden: { label: 'hidden', type: 'checkbox' },
|
||||
shouldStripGpsData: {
|
||||
label: 'strip gps data',
|
||||
type: 'hidden',
|
||||
|
||||
33
src/photo/visibility/FieldsetVisibility.tsx
Normal file
33
src/photo/visibility/FieldsetVisibility.tsx
Normal 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,
|
||||
))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/photo/visibility/PhotoVisibilityIcon.tsx
Normal file
21
src/photo/visibility/PhotoVisibilityIcon.tsx
Normal 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;
|
||||
}
|
||||
60
src/photo/visibility/index.tsx
Normal file
60
src/photo/visibility/index.tsx
Normal 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);
|
||||
@ -289,7 +289,7 @@ html {
|
||||
}
|
||||
.error {
|
||||
@apply
|
||||
border-red-500 dark:border-red-400
|
||||
outline-2 outline-red-500! dark:outline-red-400!
|
||||
}
|
||||
button, .button {
|
||||
@apply
|
||||
|
||||
Loading…
Reference in New Issue
Block a user