diff --git a/app/admin/baseline/page.tsx b/app/admin/baseline/page.tsx index 9b975fc0..631c15b1 100644 --- a/app/admin/baseline/page.tsx +++ b/app/admin/baseline/page.tsx @@ -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', )}> - setShouldShowBaselineGrid?.(e === 'true')} /> - - - - - + contentMain={
+
+ + + + +
+
+ {}} + onError={() => {}} + openOnLoad={false} + /> +
+
+ {}} + /> +
+
+ , + label: 'Always visible', + accessoryEnd: '× 2', + note: 'Exclude photo from core feeds', + }, { + value: 'hidden', + accessoryStart: , + label: 'Hide from feeds', + accessoryEnd: '× 2', + note: 'Exclude photo from core feeds', + }, { + value: 'private', + accessoryStart: , + label: 'Private', + accessoryEnd: '× 2', + note: 'Exclude photo from core feeds', + }, { + value: 'private1', + accessoryStart: , + label: 'Private', + accessoryEnd: '× 2', + note: 'Exclude photo from core feeds', + }, { + value: 'private4', + accessoryStart: , + label: 'Private', + accessoryEnd: '× 2', + note: 'Exclude photo from core feeds', + }, { + value: 'private2', + accessoryStart: , + label: 'Private', + accessoryEnd: '× 2', + note: 'Exclude photo from core feeds', + }, { + value: 'private3', + accessoryStart: , + label: 'Private', + accessoryEnd: '× 2', + note: 'Exclude photo from core feeds', + }]} + /> +
} /> ); diff --git a/src/admin/AdminBatchUploadActions.tsx b/src/admin/AdminBatchUploadActions.tsx index f5576e26..a2b6e8bd 100644 --- a/src/admin/AdminBatchUploadActions.tsx +++ b/src/admin/AdminBatchUploadActions.tsx @@ -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>({}); 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')}`} - setFormData(data => ({ ...data, tags }))} onError={setTagErrorMessage} readOnly={isAdding} className="relative z-10" /> -
-
+ + + setFormData(data => ({ ...data, favorite }))} + readOnly={isAdding} + /> }
{titleForPhoto(photo, false)} - {photo.excludeFromFeeds && !photo.hidden && - - - } - {photo.hidden && - - - } + + + {photo.priorityOrder !== null && - -
- diff --git a/src/admin/PhotoTagFieldset.tsx b/src/admin/PhotoTagFieldset.tsx index 17ff1464..5c72a409 100644 --- a/src/admin/PhotoTagFieldset.tsx +++ b/src/admin/PhotoTagFieldset.tsx @@ -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, + ComponentProps, 'tagOptions' >>) { const { @@ -41,7 +41,7 @@ export default function PhotoTagFieldset(props: { return (
- - + {GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull} {GRID_HOMEPAGE_ENABLED ? renderItemFull : renderItemGrid} {/* Show spinner if admin is suspected to be logged in */} diff --git a/src/app/ThemeSwitcher.tsx b/src/app/ThemeSwitcher.tsx index 8de2624b..777318a8 100644 --- a/src/app/ThemeSwitcher.tsx +++ b/src/app/ThemeSwitcher.tsx @@ -23,7 +23,10 @@ export default function ThemeSwitcher () { } return ( - + } onClick={() => setTheme('system')} diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index 5526a9f3..7f137f6f 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -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} }
- - ({ label: `Toggle ${label}`, action: () => onToggle?.(prev => !prev), - annotation: isEnabled ? : undefined, + annotation: isEnabled ? : undefined, }); export default function CommandKClient({ diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index b4da7cb0..c9c1e811 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -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({ }
{selectOptions - ?
- -
- -
-
+ ? : tagOptions ? void + options: SelectMenuOptionType[] + defaultOptionLabel?: string + tabIndex?: number + error?: string + readOnly?: boolean + children?: ReactNode +}) { + const ARIA_ID_SELECT_OPTIONS = `select-options-${name}`; + + const ref = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [selectedOptionIndex, setSelectedOptionIndex] = useState(); + 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 ( +
+
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 ??
+
+ +
+ +
} + +
+
+ {isOpen && +
+ + {defaultOptionLabel && + } + {options.map((option, index) => + { + onChange?.(option.value); + setIsOpen(false); + }} + />)} + +
} +
+
+ ); +} diff --git a/src/components/SelectMenuOption.tsx b/src/components/SelectMenuOption.tsx new file mode 100644 index 00000000..4ae6869f --- /dev/null +++ b/src/components/SelectMenuOption.tsx @@ -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(null); + + useEffect(() => { + if (isHighlighted) { + ref.current?.scrollIntoView({ block: 'nearest' }); + } + }, [isHighlighted]); + + return ( +
+
+ {accessoryStart && +
+ {accessoryStart} +
} +
+
{label}
+ {note && +
+ {note} +
} +
+ {(accessoryEnd || isSelected) && +
+ {isSelected + ? + : accessoryEnd} +
} +
+
+ ); +} diff --git a/src/components/Switcher.tsx b/src/components/Switcher.tsx index c3172023..caff4a2a 100644 --- a/src/components/Switcher.tsx +++ b/src/components/Switcher.tsx @@ -13,8 +13,6 @@ export default function Switcher({ return (
; +} \ No newline at end of file diff --git a/src/components/icons/IconSelectChevron.tsx b/src/components/icons/IconSelectChevron.tsx new file mode 100644 index 00000000..22c782d1 --- /dev/null +++ b/src/components/icons/IconSelectChevron.tsx @@ -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 ( + + ); +} diff --git a/src/components/useMaskedScroll.ts b/src/components/useMaskedScroll.ts index 766b1e92..b2d431de 100644 --- a/src/components/useMaskedScroll.ts +++ b/src/components/useMaskedScroll.ts @@ -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; diff --git a/src/photo/ai/AiButton.tsx b/src/photo/ai/AiButton.tsx index 60b5efb6..01938386 100644 --- a/src/photo/ai/AiButton.tsx +++ b/src/photo/ai/AiButton.tsx @@ -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) { const isLoading = useMemo(() => (requestFields ?? []).map(field => { switch (field) { @@ -41,7 +41,6 @@ export default function AiButton({ return ( } - className={className} onClick={e => { if ( !shouldConfirm || @@ -53,6 +52,7 @@ export default function AiButton({ } }} isLoading={isLoading} + {...props} /> ); } diff --git a/src/photo/form/ApplyRecipesGloballyCheckbox.tsx b/src/photo/form/ApplyRecipesGloballyCheckbox.tsx index 38754a77..93f44e22 100644 --- a/src/photo/form/ApplyRecipesGloballyCheckbox.tsx +++ b/src/photo/form/ApplyRecipesGloballyCheckbox.tsx @@ -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 & { +}: ComponentProps & { photoId?: string recipeTitle?: string hasRecipeTitleChanged?: boolean @@ -40,7 +40,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({ return ( shouldShowFieldSet - ? , - 'label' | 'icon' | 'type' ->) { - return ( - } - tooltip="Do not show on homepage views or RSS" - /> - ); -} diff --git a/src/photo/form/FieldsetFavs.tsx b/src/photo/form/FieldsetFavs.tsx index 122e273d..71e939cc 100644 --- a/src/photo/form/FieldsetFavs.tsx +++ b/src/photo/form/FieldsetFavs.tsx @@ -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, + ComponentProps, 'label' | 'icon' | 'type' >) { return ( - , - 'label' | 'icon' | 'type' ->) { - return ( - } - tooltip="Visible only to authenticated admin" - /> - ); -} diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 4265b309..24dc25e1 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -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 ; case 'caption': return ; case 'tags': return ; case 'semanticDescription': return { - 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 = { + const fieldProps: ComponentProps = { 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 } - {...fieldProps} />; case 'applyRecipeTitleGlobally': return ; + case 'visibility': + return ; case 'favorite': return ; - case 'excludeFromFeeds': - return ; - case 'hidden': - return ; default: - return ; diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 37981012..088cbac7 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -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', diff --git a/src/photo/visibility/FieldsetVisibility.tsx b/src/photo/visibility/FieldsetVisibility.tsx new file mode 100644 index 00000000..91836743 --- /dev/null +++ b/src/photo/visibility/FieldsetVisibility.tsx @@ -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 + setFormData: Dispatch>> +} & Omit, 'label' | 'value'>) { + return ( + setFormData(data => + updateFormDataWithVisibility( + data, + value as VisibilityValue, + ))} + /> + ); +} diff --git a/src/photo/visibility/PhotoVisibilityIcon.tsx b/src/photo/visibility/PhotoVisibilityIcon.tsx new file mode 100644 index 00000000..de995681 --- /dev/null +++ b/src/photo/visibility/PhotoVisibilityIcon.tsx @@ -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 + ? + + + : photo.excludeFromFeeds + ? + + + : null; +} diff --git a/src/photo/visibility/index.tsx b/src/photo/visibility/index.tsx new file mode 100644 index 00000000..88b72317 --- /dev/null +++ b/src/photo/visibility/index.tsx @@ -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: , + label: 'Default', + note: 'Viewable everywhere', + }, + { + value: 'exclude', + accessoryStart: , + label: 'Hide from feeds', + note: EXCLUDE_DESCRIPTION, + }, + { + value: 'private', + accessoryStart: , + label: 'Private', + note: PRIVATE_DESCRIPTION, + }, +]; + +export const getVisibilityValue = ( + formData: Partial, +): VisibilityValue => + formData.hidden === 'true' + ? 'private' + : formData.excludeFromFeeds === 'true' + ? 'exclude' + : 'default'; + +export const updateFormDataWithVisibility = ( + formData: Partial, + value: VisibilityValue, +): Partial => { + return { + ...formData, + ...value === 'private' + ? { hidden: 'true', excludeFromFeeds: 'false' } + : value === 'exclude' + ? { hidden: 'false', excludeFromFeeds: 'true' } + : { hidden: 'false', excludeFromFeeds: 'false' }, + }; +}; + +export const didVisibilityChange = ( + original: Partial, + current: Partial, +) => getVisibilityValue(original) !== getVisibilityValue(current); diff --git a/tailwind.css b/tailwind.css index 903b967b..0ca728e3 100644 --- a/tailwind.css +++ b/tailwind.css @@ -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