Merge branch 'main' into static
This commit is contained in:
commit
9e1ad1a4db
@ -12,7 +12,7 @@ export default async function PhotoEditPage({
|
||||
|
||||
if (!photo) { redirect(PATH_ADMIN); }
|
||||
|
||||
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag);
|
||||
const uniqueTags = await getUniqueTagsCached();
|
||||
|
||||
return (
|
||||
<PhotoEditPageClient {...{ photo, uniqueTags }} />
|
||||
|
||||
@ -15,7 +15,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
||||
photoFormExif,
|
||||
} = await extractExifDataFromBlobPath(uploadPath);
|
||||
|
||||
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag);
|
||||
const uniqueTags = await getUniqueTagsCached();
|
||||
|
||||
if (!photoFormExif) { redirect(PATH_ADMIN); }
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { LegacyRef } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import Spinner from './Spinner';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { FieldSetType } from '@/photo/form';
|
||||
import { FieldSetType, AnnotatedTag } from '@/photo/form';
|
||||
import TagInput from './TagInput';
|
||||
|
||||
export default function FieldSetWithStatus({
|
||||
@ -16,7 +16,7 @@ export default function FieldSetWithStatus({
|
||||
onChange,
|
||||
selectOptions,
|
||||
selectOptionsDefaultLabel,
|
||||
commaSeparatedOptions,
|
||||
tagOptions,
|
||||
placeholder,
|
||||
loading,
|
||||
required,
|
||||
@ -33,7 +33,7 @@ export default function FieldSetWithStatus({
|
||||
onChange?: (value: string) => void
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
selectOptionsDefaultLabel?: string
|
||||
commaSeparatedOptions?: string[]
|
||||
tagOptions?: AnnotatedTag []
|
||||
placeholder?: string
|
||||
loading?: boolean
|
||||
required?: boolean
|
||||
@ -91,11 +91,11 @@ export default function FieldSetWithStatus({
|
||||
{optionLabel}
|
||||
</option>)}
|
||||
</select>
|
||||
: commaSeparatedOptions
|
||||
: tagOptions
|
||||
? <TagInput
|
||||
name={id}
|
||||
value={value}
|
||||
options={commaSeparatedOptions}
|
||||
options={tagOptions}
|
||||
onChange={onChange}
|
||||
className={clsx(Boolean(error) && 'error')}
|
||||
readOnly={readOnly || pending}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { AnnotatedTag } from '@/photo/form';
|
||||
import { convertStringToArray, parameterize } from '@/utility/string';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const KEYDOWN_KEY = 'keydown';
|
||||
const CREATE_LABEL = 'Create ';
|
||||
const KEY_KEYDOWN = 'keydown';
|
||||
const CREATE_LABEL = 'Create';
|
||||
|
||||
const ARIA_ID_TAG_CONTROL = 'tag-control';
|
||||
const ARIA_ID_TAG_OPTIONS = 'tag-options';
|
||||
|
||||
export default function TagInput({
|
||||
name,
|
||||
@ -15,7 +19,7 @@ export default function TagInput({
|
||||
}: {
|
||||
name: string
|
||||
value?: string
|
||||
options?: string[]
|
||||
options?: AnnotatedTag[]
|
||||
onChange?: (value: string) => void
|
||||
className?: string
|
||||
readOnly?: boolean
|
||||
@ -24,10 +28,14 @@ export default function TagInput({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const optionsRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [hasFocus, setHasFocus] = useState(false);
|
||||
const [shouldShowMenu, setShouldShowMenu] = useState(false);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
|
||||
|
||||
const optionValues = useMemo(() =>
|
||||
options.map(({ value }) => value)
|
||||
, [options]);
|
||||
|
||||
const selectedOptions = useMemo(() =>
|
||||
convertStringToArray(value) ?? []
|
||||
, [value]);
|
||||
@ -35,25 +43,36 @@ export default function TagInput({
|
||||
const inputTextFormatted = parameterize(inputText);
|
||||
const isInputTextUnique =
|
||||
inputTextFormatted &&
|
||||
!options.includes(inputTextFormatted) &&
|
||||
!optionValues.includes(inputTextFormatted) &&
|
||||
!selectedOptions.includes(inputTextFormatted);
|
||||
|
||||
const optionsFiltered = (isInputTextUnique
|
||||
? [`${CREATE_LABEL}"${inputTextFormatted}"`]
|
||||
: []).concat(options
|
||||
.filter(option =>
|
||||
!selectedOptions.includes(option) &&
|
||||
const optionsFiltered = useMemo<AnnotatedTag[]>(() =>
|
||||
(isInputTextUnique
|
||||
? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }]
|
||||
: []
|
||||
).concat(options
|
||||
.filter(({ value }) =>
|
||||
!selectedOptions.includes(value) &&
|
||||
(
|
||||
!inputTextFormatted ||
|
||||
option.includes(inputTextFormatted)
|
||||
)));
|
||||
value.includes(inputTextFormatted)
|
||||
)))
|
||||
, [inputTextFormatted, isInputTextUnique, options, selectedOptions]);
|
||||
|
||||
const hideMenu = useCallback((shouldBlurInput?: boolean) => {
|
||||
setShouldShowMenu(false);
|
||||
setSelectedOptionIndex(undefined);
|
||||
if (shouldBlurInput) {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addOption = useCallback((option?: string) => {
|
||||
if (option && !selectedOptions.includes(option)) {
|
||||
onChange?.([
|
||||
...selectedOptions,
|
||||
option.startsWith(CREATE_LABEL)
|
||||
? option.slice(CREATE_LABEL.length + 1, -1)
|
||||
? option.slice(CREATE_LABEL.length, -1)
|
||||
: option,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@ -71,15 +90,17 @@ export default function TagInput({
|
||||
inputRef.current?.focus();
|
||||
}, [onChange, selectedOptions]);
|
||||
|
||||
// Reset selected option index when focus is lost
|
||||
// Show options when input text changes
|
||||
useEffect(() => {
|
||||
if (!hasFocus) { setSelectedOptionIndex(undefined); }
|
||||
}, [hasFocus]);
|
||||
if (inputText) {
|
||||
setShouldShowMenu(true);
|
||||
}
|
||||
}, [inputText]);
|
||||
|
||||
// Focus option in the DOM when selected index changes
|
||||
useEffect(() => {
|
||||
if (selectedOptionIndex !== undefined) {
|
||||
const options = optionsRef.current?.querySelectorAll('div');
|
||||
const options = optionsRef.current?.querySelectorAll(':scope > div');
|
||||
const option = options?.[selectedOptionIndex] as HTMLElement | undefined;
|
||||
option?.focus();
|
||||
}
|
||||
@ -88,6 +109,7 @@ export default function TagInput({
|
||||
// Setup keyboard listener
|
||||
useEffect(() => {
|
||||
const ref = containerRef.current;
|
||||
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
// Keys which always trap focus
|
||||
switch (e.key) {
|
||||
@ -102,12 +124,12 @@ export default function TagInput({
|
||||
case 'Enter':
|
||||
// Only trap focus if there are options to select
|
||||
// otherwise allow form to submit
|
||||
if (optionsFiltered.length > 0) {
|
||||
if (shouldShowMenu && optionsFiltered.length > 0) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
addOption(optionsFiltered[selectedOptionIndex ?? 0]);
|
||||
addOption(optionsFiltered[selectedOptionIndex ?? 0].value);
|
||||
setInputText('');
|
||||
}
|
||||
break;
|
||||
case ',':
|
||||
addOption(inputText);
|
||||
@ -126,7 +148,12 @@ export default function TagInput({
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
setSelectedOptionIndex(i => {
|
||||
if (i === undefined || i === 0) {
|
||||
if (
|
||||
document.activeElement === inputRef.current &&
|
||||
optionsFiltered.length > 0
|
||||
) {
|
||||
return optionsFiltered.length - 1;
|
||||
} else if (i === undefined || i === 0) {
|
||||
inputRef.current?.focus();
|
||||
return undefined;
|
||||
} else {
|
||||
@ -137,56 +164,76 @@ export default function TagInput({
|
||||
case 'Backspace':
|
||||
if (inputText === '' && selectedOptions.length > 0) {
|
||||
removeOption(selectedOptions[selectedOptions.length - 1]);
|
||||
hideMenu();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
inputRef.current?.blur();
|
||||
setHasFocus(false);
|
||||
hideMenu(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
ref?.addEventListener(KEYDOWN_KEY, listener);
|
||||
return () => ref?.removeEventListener(KEYDOWN_KEY, listener);
|
||||
|
||||
ref?.addEventListener(KEY_KEYDOWN, listener);
|
||||
|
||||
return () => ref?.removeEventListener(KEY_KEYDOWN, listener);
|
||||
}, [
|
||||
inputText,
|
||||
removeOption,
|
||||
hasFocus,
|
||||
hideMenu,
|
||||
selectedOptions,
|
||||
selectedOptionIndex,
|
||||
optionsFiltered,
|
||||
addOption,
|
||||
shouldShowMenu,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col w-full"
|
||||
onFocus={() => setHasFocus(true)}
|
||||
className="flex flex-col w-full group"
|
||||
onFocus={() => setShouldShowMenu(true)}
|
||||
onBlur={e => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setHasFocus(false);
|
||||
setSelectedOptionIndex(undefined);
|
||||
hideMenu();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={clsx(
|
||||
<div
|
||||
id={ARIA_ID_TAG_CONTROL}
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
className="sr-only mb-3 text-dim"
|
||||
>
|
||||
{selectedOptions.length === 0
|
||||
? 'No tags selected'
|
||||
: selectedOptions.join(', ') +
|
||||
` tag${selectedOptions.length !== 1 ? 's' : ''} selected`}
|
||||
</div>
|
||||
<div
|
||||
aria-controls={ARIA_ID_TAG_CONTROL}
|
||||
className={clsx(
|
||||
className,
|
||||
'w-full control !px-2 !py-2',
|
||||
'outline-1 outline-blue-600',
|
||||
'group-focus-within:outline group-active:outline',
|
||||
'inline-flex flex-wrap items-center gap-2',
|
||||
readOnly && 'cursor-not-allowed',
|
||||
readOnly && 'bg-gray-100 dark:bg-gray-900 dark:text-gray-400',
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{selectedOptions
|
||||
.filter(Boolean)
|
||||
.map(option =>
|
||||
<span
|
||||
key={option}
|
||||
role="button"
|
||||
aria-label={`Remove tag "${option}"`}
|
||||
className={clsx(
|
||||
'cursor-pointer select-none',
|
||||
'whitespace-nowrap',
|
||||
'px-1.5 py-0.5',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'active:bg-gray-50 dark:active:bg-gray-900',
|
||||
'bg-gray-200/60 dark:bg-gray-800',
|
||||
'active:bg-gray-200 dark:active:bg-gray-900',
|
||||
'rounded-sm',
|
||||
)}
|
||||
onClick={() => removeOption(option)}
|
||||
@ -206,43 +253,64 @@ export default function TagInput({
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
readOnly={readOnly}
|
||||
onFocus={() => setSelectedOptionIndex(undefined)}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={shouldShowMenu}
|
||||
aria-haspopup="true"
|
||||
aria-controls={shouldShowMenu ? ARIA_ID_TAG_OPTIONS : undefined}
|
||||
role="combobox"
|
||||
/>
|
||||
<input type="hidden" name={name} value={value} />
|
||||
</div>
|
||||
{shouldShowMenu && optionsFiltered.length > 0 &&
|
||||
<div className="relative">
|
||||
<div
|
||||
id={ARIA_ID_TAG_OPTIONS}
|
||||
role="listbox"
|
||||
ref={optionsRef}
|
||||
className={clsx(
|
||||
!(hasFocus && optionsFiltered.length > 0) && 'hidden',
|
||||
'control absolute top-0 mt-3 w-full z-10 !px-1.5 !py-1.5',
|
||||
'max-h-[8rem] overflow-y-auto',
|
||||
'flex flex-col gap-y-1',
|
||||
'text-xl shadow-lg dark:shadow-xl',
|
||||
)}
|
||||
>
|
||||
{optionsFiltered.map((option, index) =>
|
||||
{optionsFiltered.map(({ value, annotation }, index) =>
|
||||
<div
|
||||
key={option}
|
||||
key={value}
|
||||
role="option"
|
||||
aria-selected={
|
||||
index === selectedOptionIndex ||
|
||||
(index === 0 && selectedOptionIndex === undefined)
|
||||
}
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'group flex items-center gap-1',
|
||||
'cursor-pointer select-none',
|
||||
'px-1 py-1 rounded-sm',
|
||||
index === 0 && selectedOptionIndex === undefined &&
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'px-1.5 py-1 rounded-sm',
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
'active:bg-gray-50 dark:active:bg-gray-900',
|
||||
'focus:bg-gray-100 dark:focus:bg-gray-800',
|
||||
index === 0 && selectedOptionIndex === undefined &&
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'outline-none',
|
||||
)}
|
||||
onClick={() => {
|
||||
addOption(option);
|
||||
addOption(value);
|
||||
setInputText('');
|
||||
}}
|
||||
onFocus={() => setSelectedOptionIndex(index)}
|
||||
>
|
||||
{option}
|
||||
<span className="grow min-w-0 truncate">
|
||||
{value}
|
||||
</span>
|
||||
{annotation &&
|
||||
<span className="whitespace-nowrap text-dim text-sm">
|
||||
{annotation}
|
||||
</span>}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,13 +10,14 @@ import { useFormState } from 'react-dom';
|
||||
import { areSimpleObjectsEqual } from '@/utility/object';
|
||||
import IconGrSync from '@/site/IconGrSync';
|
||||
import { getExifDataAction } from './actions';
|
||||
import { Tags } from '@/tag';
|
||||
|
||||
export default function PhotoEditPageClient({
|
||||
photo,
|
||||
uniqueTags,
|
||||
}: {
|
||||
photo: Photo
|
||||
uniqueTags?: string[]
|
||||
uniqueTags?: Tags
|
||||
}) {
|
||||
const seedExifData = { url: photo.url };
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
FORM_METADATA_ENTRIES,
|
||||
PhotoFormData,
|
||||
convertFormKeysToLabels,
|
||||
getInitialErrors,
|
||||
getFormErrors,
|
||||
isFormValid,
|
||||
} from '.';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
@ -23,7 +23,7 @@ import { toastSuccess, toastWarning } from '@/toast';
|
||||
import { getDimensionsFromSize } from '@/utility/size';
|
||||
import ImageBlurFallback from '@/components/ImageBlurFallback';
|
||||
import { BLUR_ENABLED } from '@/site/config';
|
||||
import { sortTagsWithoutFavs } from '@/tag';
|
||||
import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
|
||||
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
|
||||
@ -37,13 +37,13 @@ export default function PhotoForm({
|
||||
initialPhotoForm: Partial<PhotoFormData>
|
||||
updatedExifData?: Partial<PhotoFormData>
|
||||
type?: 'create' | 'edit'
|
||||
uniqueTags?: string[]
|
||||
uniqueTags?: Tags
|
||||
debugBlur?: boolean
|
||||
}) {
|
||||
const [formData, setFormData] =
|
||||
useState<Partial<PhotoFormData>>(initialPhotoForm);
|
||||
const [formErrors, setFormErrors] =
|
||||
useState(getInitialErrors(initialPhotoForm));
|
||||
useState(getFormErrors(initialPhotoForm));
|
||||
|
||||
// Update form when EXIF data
|
||||
// is refreshed by parent
|
||||
@ -146,12 +146,20 @@ export default function PhotoForm({
|
||||
onSubmit={() => blur()}
|
||||
className="space-y-6"
|
||||
>
|
||||
{FORM_METADATA_ENTRIES.map(([key, {
|
||||
{FORM_METADATA_ENTRIES(
|
||||
sortTagsObjectWithoutFavs(uniqueTags ?? [])
|
||||
.map(({ tag, count }) => ({
|
||||
value: tag,
|
||||
annotation: `× ${count}`,
|
||||
}))
|
||||
)
|
||||
.map(([key, {
|
||||
label,
|
||||
note,
|
||||
required,
|
||||
options,
|
||||
optionsDefaultLabel,
|
||||
selectOptions,
|
||||
selectOptionsDefaultLabel,
|
||||
tagOptions,
|
||||
readOnly,
|
||||
validate,
|
||||
capitalize,
|
||||
@ -177,11 +185,9 @@ export default function PhotoForm({
|
||||
setFormErrors({ ...formErrors, [key]: validate(value) });
|
||||
}
|
||||
}}
|
||||
selectOptions={options}
|
||||
selectOptionsDefaultLabel={optionsDefaultLabel}
|
||||
commaSeparatedOptions={key === 'tags'
|
||||
? sortTagsWithoutFavs(uniqueTags ?? [])
|
||||
: undefined}
|
||||
selectOptions={selectOptions}
|
||||
selectOptionsDefaultLabel={selectOptionsDefaultLabel}
|
||||
tagOptions={tagOptions}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
capitalize={capitalize}
|
||||
|
||||
@ -26,6 +26,8 @@ export type FieldSetType =
|
||||
'password' |
|
||||
'checkbox';
|
||||
|
||||
export type AnnotatedTag = { value: string, annotation?: string };
|
||||
|
||||
type FormMeta = {
|
||||
label: string
|
||||
note?: string
|
||||
@ -39,14 +41,18 @@ type FormMeta = {
|
||||
hideBasedOnCamera?: (make?: string, mode?: string) => boolean
|
||||
loadingMessage?: string
|
||||
type?: FieldSetType
|
||||
options?: { value: string, label: string }[]
|
||||
optionsDefaultLabel?: string
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
selectOptionsDefaultLabel?: string
|
||||
tagOptions?: AnnotatedTag[]
|
||||
};
|
||||
|
||||
const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
||||
const FORM_METADATA = (
|
||||
tagOptions?: AnnotatedTag[]
|
||||
): Record<keyof PhotoFormData, FormMeta> => ({
|
||||
title: { label: 'title', capitalize: true },
|
||||
tags: {
|
||||
label: 'tags',
|
||||
tagOptions,
|
||||
validate: tags => doesTagsStringIncludeFavs(tags)
|
||||
? `'${TAG_FAVS}' is a reserved tag`
|
||||
: undefined,
|
||||
@ -66,8 +72,8 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
||||
model: { label: 'camera model' },
|
||||
filmSimulation: {
|
||||
label: 'fujifilm simulation',
|
||||
options: FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||
optionsDefaultLabel: 'Unknown',
|
||||
selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||
selectOptionsDefaultLabel: 'Unknown',
|
||||
hideBasedOnCamera: make => make !== MAKE_FUJIFILM,
|
||||
},
|
||||
focalLength: { label: 'focal length' },
|
||||
@ -84,26 +90,28 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
||||
priorityOrder: { label: 'priority order' },
|
||||
favorite: { label: 'favorite', type: 'checkbox', virtual: true },
|
||||
hidden: { label: 'hidden', type: 'checkbox' },
|
||||
};
|
||||
});
|
||||
|
||||
export const FORM_METADATA_ENTRIES =
|
||||
(Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][])
|
||||
export const FORM_METADATA_ENTRIES = (
|
||||
...args: Parameters<typeof FORM_METADATA>
|
||||
) =>
|
||||
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][])
|
||||
.filter(([_, meta]) => !meta.hide);
|
||||
|
||||
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) =>
|
||||
keys.map(key => FORM_METADATA[key].label.toUpperCase());
|
||||
keys.map(key => FORM_METADATA()[key].label.toUpperCase());
|
||||
|
||||
export const getInitialErrors = (
|
||||
export const getFormErrors = (
|
||||
formData: Partial<PhotoFormData>
|
||||
): Partial<Record<keyof PhotoFormData, string>> =>
|
||||
Object.keys(formData).reduce((acc, key) => ({
|
||||
...acc,
|
||||
[key]: FORM_METADATA_ENTRIES.find(([k]) => k === key)?.[1]
|
||||
[key]: FORM_METADATA_ENTRIES().find(([k]) => k === key)?.[1]
|
||||
.validate?.(formData[key as keyof PhotoFormData]),
|
||||
}), {});
|
||||
|
||||
export const isFormValid = (formData: Partial<PhotoFormData>) =>
|
||||
FORM_METADATA_ENTRIES.every(
|
||||
FORM_METADATA_ENTRIES().every(
|
||||
([key, { required, validate }]) =>
|
||||
(!required || Boolean(formData[key])) &&
|
||||
(validate?.(formData[key]) === undefined)
|
||||
@ -191,7 +199,7 @@ export const convertFormDataToPhotoDbInsert = (
|
||||
if (
|
||||
key.startsWith('$ACTION_ID_') ||
|
||||
(photoForm as any)[key] === '' ||
|
||||
FORM_METADATA[key as keyof PhotoFormData]?.virtual
|
||||
FORM_METADATA()[key as keyof PhotoFormData]?.virtual
|
||||
) {
|
||||
delete (photoForm as any)[key];
|
||||
}
|
||||
|
||||
@ -46,6 +46,9 @@ export const sortTagsObject = (
|
||||
export const sortTagsWithoutFavs = (tags: string[]) =>
|
||||
sortTags(tags, TAG_FAVS);
|
||||
|
||||
export const sortTagsObjectWithoutFavs = (tags: Tags) =>
|
||||
sortTagsObject(tags, TAG_FAVS);
|
||||
|
||||
export const descriptionForTaggedPhotos = (
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user