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 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'}
|
||||||
|
|||||||
@ -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>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 =>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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 (
|
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' &&
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
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]);
|
}, [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;
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ...'
|
||||||
|
|||||||
@ -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 { 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"
|
||||||
|
|||||||
@ -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,
|
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}
|
||||||
/>;
|
/>;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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 {
|
.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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user