Album upgrades (#326)
* Add tag-to-album upgrade, introduce tag/album ••• menus * Refine entity ••• menus * Add album tagging to "Select ..." mode * Finalize batch select/upload add album * Refine final tag/album interactions * Refine upgradeTagToAlbum capitalization * Fix batch album upload, z-index issues * Refine readonly styles
This commit is contained in:
parent
1e66815a3d
commit
ee9f3f4dc2
17
__tests__/postgres.test.ts
Normal file
17
__tests__/postgres.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/* eslint-disable max-len */
|
||||
import { generateManyToManyValues } from '@/db';
|
||||
|
||||
describe('Postgres', () => {
|
||||
it('Create many to many values', () => {
|
||||
expect(generateManyToManyValues(['1'], ['3']))
|
||||
.toEqual({
|
||||
valueString: 'VALUES ($1,$2)',
|
||||
values: ['1', '3'],
|
||||
});
|
||||
expect(generateManyToManyValues(['1', '2'], ['3', '4', '5']))
|
||||
.toEqual({
|
||||
valueString: 'VALUES ($1,$2),($3,$4),($5,$6),($7,$8),($9,$10),($11,$12)',
|
||||
values: ['1', '3', '1', '4', '1', '5', '2', '3', '2', '4', '2', '5'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import PhotoTagFieldset from '@/admin/PhotoTagFieldset';
|
||||
import FieldsetTag from '@/tag/FieldsetTag';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import IconHidden from '@/components/icons/IconHidden';
|
||||
@ -25,7 +25,7 @@ export default function ComponentsPage() {
|
||||
<StatusIcon type="optional" />
|
||||
</div>
|
||||
<div className="z-12">
|
||||
<PhotoTagFieldset
|
||||
<FieldsetTag
|
||||
tags="tag-1"
|
||||
tagOptions={[{
|
||||
tag: 'Tag 1',
|
||||
|
||||
@ -4,11 +4,13 @@ import { getUniqueTagsCached } from '@/photo/cache';
|
||||
import AdminUploadsClient from '@/admin/AdminUploadsClient';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/app/path';
|
||||
import { getAlbumsWithMeta } from '@/album/query';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export default async function AdminUploadsPage() {
|
||||
const urls = await getStorageUploadUrlsNoStore();
|
||||
const uniqueAlbums = await getAlbumsWithMeta();
|
||||
const uniqueTags = await getUniqueTagsCached();
|
||||
|
||||
if (urls.length === 0) {
|
||||
@ -19,6 +21,7 @@ export default async function AdminUploadsPage() {
|
||||
contentMain={
|
||||
<AdminUploadsClient {...{
|
||||
urls,
|
||||
uniqueAlbums,
|
||||
uniqueTags,
|
||||
}} />}
|
||||
/>
|
||||
|
||||
@ -2,14 +2,13 @@ import FormWithConfirm from '@/components/FormWithConfirm';
|
||||
import AdminTable from '@/admin/AdminTable';
|
||||
import { Fragment } from 'react';
|
||||
import DeleteFormButton from '@/admin/DeleteFormButton';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
import EditButton from '@/admin/EditButton';
|
||||
import { pathForAdminAlbumEdit } from '@/app/path';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
import { Albums } from '@/album';
|
||||
import { Albums, deleteAlbumConfirmationText } from '@/album';
|
||||
import AdminAlbumBadge from './AdminAlbumBadge';
|
||||
import { deleteAlbumAction } from '@/album/actions';
|
||||
import { deleteAlbumFormAction } from '@/album/actions';
|
||||
|
||||
export default async function AdminAlbumsTable({
|
||||
albums,
|
||||
@ -31,10 +30,8 @@ export default async function AdminAlbumsTable({
|
||||
)}>
|
||||
<EditButton path={pathForAdminAlbumEdit(album)} />
|
||||
<FormWithConfirm
|
||||
action={deleteAlbumAction}
|
||||
confirmText={
|
||||
// eslint-disable-next-line max-len
|
||||
`Are you sure you want to remove "${album.title}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`}
|
||||
action={deleteAlbumFormAction}
|
||||
confirmText={deleteAlbumConfirmationText(album, count, appText)}
|
||||
>
|
||||
<input type="hidden" name="album" value={album.id} />
|
||||
<DeleteFormButton clearLocalState />
|
||||
|
||||
@ -17,7 +17,7 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react';
|
||||
import { BiCheckCircle } from 'react-icons/bi';
|
||||
import ProgressButton from '@/components/primitives/ProgressButton';
|
||||
import { UrlAddStatus } from './AdminUploadsClient';
|
||||
import PhotoTagFieldset from './PhotoTagFieldset';
|
||||
import FieldsetTag from '../tag/FieldsetTag';
|
||||
import DeleteUploadButton from './DeleteUploadButton';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { pluralize } from '@/utility/string';
|
||||
@ -25,12 +25,15 @@ import FieldsetFavs from '@/photo/form/FieldsetFavs';
|
||||
import IconAddUpload from '@/components/icons/IconAddUpload';
|
||||
import { PhotoFormData } from '@/photo/form';
|
||||
import FieldsetVisibility from '@/photo/visibility/FieldsetVisibility';
|
||||
import { Albums } from '@/album';
|
||||
import FieldsetAlbum from '@/album/FieldsetAlbum';
|
||||
|
||||
const UPLOAD_BATCH_SIZE = 2;
|
||||
|
||||
export default function AdminBatchUploadActions({
|
||||
uploadUrls,
|
||||
uploadTitles,
|
||||
uniqueAlbums,
|
||||
uniqueTags,
|
||||
isAdding,
|
||||
setIsAdding,
|
||||
@ -41,6 +44,7 @@ export default function AdminBatchUploadActions({
|
||||
}: {
|
||||
uploadUrls: string[]
|
||||
uploadTitles: string[]
|
||||
uniqueAlbums: Albums
|
||||
uniqueTags?: Tags
|
||||
isAdding: boolean
|
||||
setIsAdding: Dispatch<SetStateAction<boolean>>
|
||||
@ -54,6 +58,7 @@ export default function AdminBatchUploadActions({
|
||||
const [showBulkSettings, setShowBulkSettings] = useState(false);
|
||||
const [tagErrorMessage, setTagErrorMessage] = useState('');
|
||||
const [formData, setFormData] = useState<Partial<PhotoFormData>>({});
|
||||
const [albumTitles, setAlbumTitles] = useState<string>();
|
||||
|
||||
const [buttonText, setButtonText] = useState('Add All Uploads');
|
||||
const [actionErrorMessage, setActionErrorMessage] = useState('');
|
||||
@ -74,6 +79,7 @@ export default function AdminBatchUploadActions({
|
||||
uploadUrls: urls,
|
||||
uploadTitles: titles,
|
||||
...showBulkSettings && {
|
||||
albumTitles: albumTitles?.split(','),
|
||||
tags,
|
||||
favorite,
|
||||
excludeFromFeeds,
|
||||
@ -128,7 +134,7 @@ export default function AdminBatchUploadActions({
|
||||
<>
|
||||
{actionErrorMessage &&
|
||||
<ErrorNote>{actionErrorMessage}</ErrorNote>}
|
||||
<Container padding="tight" className="p-2! sm:p-3!">
|
||||
<Container padding="tight" className="p-2! sm:p-3! relative z-10">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex">
|
||||
<div className="grow text-main">
|
||||
@ -146,7 +152,14 @@ export default function AdminBatchUploadActions({
|
||||
</div>
|
||||
{showBulkSettings && !actionErrorMessage &&
|
||||
<div className="space-y-4 mb-6">
|
||||
<PhotoTagFieldset
|
||||
<FieldsetAlbum
|
||||
albumOptions={uniqueAlbums}
|
||||
value={albumTitles ?? ''}
|
||||
onChange={albums => setAlbumTitles(albums)}
|
||||
readOnly={isAdding}
|
||||
className="relative z-11"
|
||||
/>
|
||||
<FieldsetTag
|
||||
label="Tags"
|
||||
tags={formData.tags ?? ''}
|
||||
tagOptions={uniqueTags}
|
||||
|
||||
@ -20,7 +20,6 @@ import {
|
||||
} from '@/photo';
|
||||
import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
import MoreMenu, { MoreMenuSection } from '@/components/more/MoreMenu';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||
@ -34,6 +33,7 @@ import { photoNeedsToBeUpdated } from '@/photo/update';
|
||||
import { KEY_COMMANDS } from '@/photo/key-commands';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import IconLock from '@/components/icons/IconLock';
|
||||
import IconTrash from '@/components/icons/IconTrash';
|
||||
|
||||
export default function AdminPhotoMenu({
|
||||
photo,
|
||||
@ -63,7 +63,6 @@ export default function AdminPhotoMenu({
|
||||
: PATH_ROOT
|
||||
: undefined;
|
||||
|
||||
|
||||
const sectionMain = useMemo(() => {
|
||||
const items: ComponentProps<typeof MoreMenuItem>[] = [{
|
||||
label: appText.admin.edit,
|
||||
@ -154,8 +153,7 @@ export default function AdminPhotoMenu({
|
||||
const sectionDelete: MoreMenuSection = useMemo(() => ({
|
||||
items: [{
|
||||
label: appText.admin.delete,
|
||||
icon: <BiTrash
|
||||
size={15}
|
||||
icon: <IconTrash
|
||||
className="translate-x-[-1px]"
|
||||
/>,
|
||||
className: 'text-error *:hover:text-error',
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import FormWithConfirm from '@/components/FormWithConfirm';
|
||||
import { deletePhotoTagGloballyAction } from '@/photo/actions';
|
||||
import { deletePhotoTagGloballyFormAction } from '@/photo/actions';
|
||||
import AdminTable from '@/admin/AdminTable';
|
||||
import { Fragment } from 'react';
|
||||
import DeleteFormButton from '@/admin/DeleteFormButton';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
import { Tags, formatTag, sortTags } from '@/tag';
|
||||
import { Tags, deleteTagConfirmationText, sortTags } from '@/tag';
|
||||
import EditButton from '@/admin/EditButton';
|
||||
import { pathForAdminTagEdit } from '@/app/path';
|
||||
import { clsx } from 'clsx/lite';
|
||||
@ -31,10 +30,8 @@ export default async function AdminTagsTable({
|
||||
)}>
|
||||
<EditButton path={pathForAdminTagEdit(tag)} />
|
||||
<FormWithConfirm
|
||||
action={deletePhotoTagGloballyAction}
|
||||
confirmText={
|
||||
// eslint-disable-next-line max-len
|
||||
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`}
|
||||
action={deletePhotoTagGloballyFormAction}
|
||||
confirmText={deleteTagConfirmationText(tag, count, appText)}
|
||||
>
|
||||
<input type="hidden" name="tag" value={tag} />
|
||||
<DeleteFormButton clearLocalState />
|
||||
|
||||
@ -5,6 +5,7 @@ import AdminBatchUploadActions from './AdminBatchUploadActions';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Tags } from '@/tag';
|
||||
import AdminUploadsTable from './AdminUploadsTable';
|
||||
import { Albums } from '@/album';
|
||||
|
||||
export type UrlAddStatus = StorageListItem & {
|
||||
status?: 'waiting' | 'adding' | 'added'
|
||||
@ -16,9 +17,11 @@ export type UrlAddStatus = StorageListItem & {
|
||||
export default function AdminUploadsClient({
|
||||
urls,
|
||||
uniqueTags,
|
||||
uniqueAlbums,
|
||||
}: {
|
||||
urls: StorageListResponse
|
||||
uniqueTags?: Tags
|
||||
uniqueTags: Tags
|
||||
uniqueAlbums: Albums
|
||||
}) {
|
||||
const [urlAddStatuses, setUrlAddStatuses] = useState<UrlAddStatus[]>(urls);
|
||||
|
||||
@ -41,6 +44,7 @@ export default function AdminUploadsClient({
|
||||
<AdminBatchUploadActions {...{
|
||||
uploadUrls,
|
||||
uploadTitles,
|
||||
uniqueAlbums,
|
||||
uniqueTags,
|
||||
isAdding,
|
||||
setIsAdding,
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
import { getUniqueTagsCached } from '@/photo/cache';
|
||||
import AdminBatchEditPanelClient from './AdminBatchEditPanelClient';
|
||||
import { getAlbumsWithMeta } from '@/album/query';
|
||||
|
||||
export default async function AdminBatchEditPanel({
|
||||
onBatchActionComplete,
|
||||
}: {
|
||||
onBatchActionComplete?: () => Promise<void>
|
||||
}) {
|
||||
const uniqueAlbums = await getAlbumsWithMeta().catch(() => []);
|
||||
const uniqueTags = await getUniqueTagsCached().catch(() => []);
|
||||
return (
|
||||
<AdminBatchEditPanelClient {...{ uniqueTags, onBatchActionComplete }} />
|
||||
<AdminBatchEditPanelClient {...{
|
||||
uniqueAlbums,
|
||||
uniqueTags,
|
||||
onBatchActionComplete,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import { clsx } from 'clsx/lite';
|
||||
import { IoCloseSharp } from 'react-icons/io5';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TAG_FAVS, Tags } from '@/tag';
|
||||
import PhotoTagFieldset from '@/admin/PhotoTagFieldset';
|
||||
import FieldsetTag from '@/tag/FieldsetTag';
|
||||
import { tagMultiplePhotosAction } from '@/photo/actions';
|
||||
import { toastSuccess } from '@/toast';
|
||||
import DeletePhotosButton from '@/admin/DeletePhotosButton';
|
||||
@ -18,10 +18,16 @@ import IconFavs from '@/components/icons/IconFavs';
|
||||
import IconTag from '@/components/icons/IconTag';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { useSelectPhotosState } from './SelectPhotosState';
|
||||
import { Albums } from '@/album';
|
||||
import FieldsetAlbum from '@/album/FieldsetAlbum';
|
||||
import IconAlbum from '@/components/icons/IconAlbum';
|
||||
import { addPhotosToAlbumsAction } from '@/album/actions';
|
||||
|
||||
export default function AdminBatchEditPanelClient({
|
||||
uniqueAlbums,
|
||||
uniqueTags,
|
||||
}: {
|
||||
uniqueAlbums: Albums
|
||||
uniqueTags: Tags
|
||||
}) {
|
||||
const refNote = useRef<HTMLDivElement>(null);
|
||||
@ -37,6 +43,9 @@ export default function AdminBatchEditPanelClient({
|
||||
|
||||
const appText = useAppText();
|
||||
|
||||
const [albumTitles, setAlbumsTitles] = useState<string>();
|
||||
const isInAlbumMode = albumTitles !== undefined;
|
||||
|
||||
const [tags, setTags] = useState<string>();
|
||||
const [tagErrorMessage, setTagErrorMessage] = useState('');
|
||||
const isInTagMode = tags !== undefined;
|
||||
@ -55,7 +64,7 @@ export default function AdminBatchEditPanelClient({
|
||||
const renderPhotoCTA = selectedPhotoIds?.length === 0
|
||||
? <>
|
||||
<FaArrowDown />
|
||||
<ResponsiveText shortText="Select below">
|
||||
<ResponsiveText shortText="Select">
|
||||
Select photos below
|
||||
</ResponsiveText>
|
||||
</>
|
||||
@ -63,7 +72,7 @@ export default function AdminBatchEditPanelClient({
|
||||
{photosText} selected
|
||||
</ResponsiveText>;
|
||||
|
||||
const renderActions = isInTagMode
|
||||
const renderActions = isInTagMode || isInAlbumMode
|
||||
? <>
|
||||
<LoaderButton
|
||||
className="min-h-[2.5rem]"
|
||||
@ -72,39 +81,55 @@ export default function AdminBatchEditPanelClient({
|
||||
className="translate-y-[0.5px]"
|
||||
/>}
|
||||
onClick={() => {
|
||||
setAlbumsTitles(undefined);
|
||||
setTags(undefined);
|
||||
setTagErrorMessage('');
|
||||
}}
|
||||
disabled={isPerformingSelectEdit}
|
||||
>
|
||||
Cancel
|
||||
</LoaderButton>
|
||||
/>
|
||||
<LoaderButton
|
||||
className="min-h-[2.5rem]"
|
||||
icon={<FaCheck size={15} />}
|
||||
// eslint-disable-next-line max-len
|
||||
confirmText={`Are you sure you want to apply tags to ${photosText}? This action cannot be undone.`}
|
||||
confirmText={isInTagMode
|
||||
// eslint-disable-next-line max-len
|
||||
? `Are you sure you want to apply tags to ${photosText}? This action cannot be undone.`
|
||||
// eslint-disable-next-line max-len
|
||||
: `Are you sure you want to add ${photosText} to these albums? This action cannot be undone.`}
|
||||
onClick={() => {
|
||||
setIsPerformingSelectEdit?.(true);
|
||||
tagMultiplePhotosAction(
|
||||
tags,
|
||||
selectedPhotoIds ?? [],
|
||||
)
|
||||
.then(() => {
|
||||
toastSuccess(`${photosText} tagged`);
|
||||
stopSelectingPhotos?.();
|
||||
})
|
||||
.finally(() => setIsPerformingSelectEdit?.(false));
|
||||
if (isInTagMode) {
|
||||
tagMultiplePhotosAction(
|
||||
tags,
|
||||
selectedPhotoIds ?? [],
|
||||
)
|
||||
.then(() => {
|
||||
toastSuccess(`${photosText} tagged`);
|
||||
stopSelectingPhotos?.();
|
||||
})
|
||||
.finally(() => setIsPerformingSelectEdit?.(false));
|
||||
} else if (isInAlbumMode) {
|
||||
addPhotosToAlbumsAction(
|
||||
selectedPhotoIds ?? [],
|
||||
albumTitles.split(','),
|
||||
)
|
||||
.then(() => {
|
||||
toastSuccess(`${photosText} added`);
|
||||
stopSelectingPhotos?.();
|
||||
})
|
||||
.finally(() => setIsPerformingSelectEdit?.(false));
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!tags ||
|
||||
Boolean(tagErrorMessage) ||
|
||||
(
|
||||
(!tags || Boolean(tagErrorMessage)) &&
|
||||
!albumTitles
|
||||
) ||
|
||||
(selectedPhotoIds?.length ?? 0) === 0 ||
|
||||
isPerformingSelectEdit
|
||||
}
|
||||
primary
|
||||
>
|
||||
Apply Tags
|
||||
Apply
|
||||
</LoaderButton>
|
||||
</>
|
||||
: <>
|
||||
@ -132,12 +157,19 @@ export default function AdminBatchEditPanelClient({
|
||||
.finally(() => setIsPerformingSelectEdit?.(false));
|
||||
}}
|
||||
/>
|
||||
<LoaderButton
|
||||
onClick={() => setAlbumsTitles('')}
|
||||
disabled={isFormDisabled}
|
||||
icon={<IconAlbum size={15} className="translate-y-[1.5px]" />}
|
||||
>
|
||||
Album
|
||||
</LoaderButton>
|
||||
<LoaderButton
|
||||
onClick={() => setTags('')}
|
||||
disabled={isFormDisabled}
|
||||
icon={<IconTag size={15} className="translate-y-[1.5px]" />}
|
||||
>
|
||||
Tag ...
|
||||
Tag
|
||||
</LoaderButton>
|
||||
<LoaderButton
|
||||
icon={<IoCloseSharp size={19} />}
|
||||
@ -178,20 +210,29 @@ export default function AdminBatchEditPanelClient({
|
||||
spaceChildren={false}
|
||||
hideIcon
|
||||
>
|
||||
{isInTagMode
|
||||
? <PhotoTagFieldset
|
||||
tags={tags}
|
||||
tagOptions={uniqueTags}
|
||||
placeholder={`Tag ${photosText} ...`}
|
||||
onChange={setTags}
|
||||
onError={setTagErrorMessage}
|
||||
{isInAlbumMode
|
||||
? <FieldsetAlbum
|
||||
albumOptions={uniqueAlbums}
|
||||
value={albumTitles}
|
||||
onChange={setAlbumsTitles}
|
||||
readOnly={isPerformingSelectEdit}
|
||||
openOnLoad
|
||||
hideLabel
|
||||
/>
|
||||
: <div className="text-base flex gap-2 items-center">
|
||||
{renderPhotoCTA}
|
||||
</div>}
|
||||
: isInTagMode
|
||||
? <FieldsetTag
|
||||
tags={tags}
|
||||
tagOptions={uniqueTags}
|
||||
placeholder={`Tag ${photosText} ...`}
|
||||
onChange={setTags}
|
||||
onError={setTagErrorMessage}
|
||||
readOnly={isPerformingSelectEdit}
|
||||
openOnLoad
|
||||
hideLabel
|
||||
/>
|
||||
: <div className="text-base flex gap-2 items-center">
|
||||
{renderPhotoCTA}
|
||||
</div>}
|
||||
</Note>
|
||||
{tagErrorMessage &&
|
||||
<div className="text-error pl-4">
|
||||
|
||||
52
src/album/AdminAlbumMenu.tsx
Normal file
52
src/album/AdminAlbumMenu.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import MoreMenu from '@/components/more/MoreMenu';
|
||||
import { pathForAdminAlbumEdit } from '@/app/path';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import IconEdit from '@/components/icons/IconEdit';
|
||||
import { Album, deleteAlbumConfirmationText } from '.';
|
||||
import { deleteAlbumAction } from './actions';
|
||||
import IconTrash from '@/components/icons/IconTrash';
|
||||
|
||||
export default function AdminAlbumMenu({
|
||||
album,
|
||||
count,
|
||||
}: {
|
||||
album: Album
|
||||
count: number
|
||||
}) {
|
||||
const appText = useAppText();
|
||||
const path = usePathname();
|
||||
|
||||
return (
|
||||
<MoreMenu
|
||||
ariaLabel="Album menu"
|
||||
className="m-2"
|
||||
classNameButton="h-3.5 translate-y-1"
|
||||
side="right"
|
||||
sections={[{
|
||||
items: [{
|
||||
label: 'Edit',
|
||||
icon: <IconEdit
|
||||
size={15}
|
||||
className="translate-y-[0.5px]"
|
||||
/>,
|
||||
href: pathForAdminAlbumEdit(album),
|
||||
}],
|
||||
}, {
|
||||
items: [{
|
||||
icon: <IconTrash
|
||||
className="translate-x-[-1px]"
|
||||
/>,
|
||||
label: 'Delete',
|
||||
className: 'text-error *:hover:text-error',
|
||||
color: 'red',
|
||||
action: () => {
|
||||
if (confirm(deleteAlbumConfirmationText(album, count, appText))) {
|
||||
return deleteAlbumAction(album, path);
|
||||
}
|
||||
},
|
||||
}],
|
||||
}]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import {
|
||||
SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
} from '@/app/config';
|
||||
import { getAppText } from '@/i18n/state/server';
|
||||
import { Album, descriptionForAlbumPhotos } from '.';
|
||||
import { Album, albumHasMeta, descriptionForAlbumPhotos } from '.';
|
||||
import { safelyParseFormattedHtml } from '@/utility/html';
|
||||
import PhotoAlbum from './PhotoAlbum';
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
@ -39,6 +39,7 @@ export default async function AlbumHeader({
|
||||
album={album}
|
||||
contrast="high"
|
||||
hoverType="none"
|
||||
showAdminMenu
|
||||
/>}
|
||||
entityDescription={descriptionForAlbumPhotos(
|
||||
photos,
|
||||
@ -51,7 +52,7 @@ export default async function AlbumHeader({
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
richContent={showAlbumMeta
|
||||
richContent={showAlbumMeta && (albumHasMeta(album) || tags.length > 0)
|
||||
? <div className="space-y-2">
|
||||
{album.subhead &&
|
||||
<div className="text-medium mb-6 uppercase font-medium">
|
||||
|
||||
@ -1,19 +1,37 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { ComponentProps, useEffect, useRef } from 'react';
|
||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||
import { Albums } from '.';
|
||||
import { convertAlbumsToAnnotatedTags } from './form';
|
||||
|
||||
export default function FieldsetAlbum({
|
||||
albumOptions,
|
||||
label,
|
||||
openOnLoad,
|
||||
...props
|
||||
}: {
|
||||
albumOptions: Albums
|
||||
} & ComponentProps<typeof FieldsetWithStatus>) {
|
||||
label?: string
|
||||
openOnLoad?: boolean
|
||||
} & Omit<ComponentProps<typeof FieldsetWithStatus>, 'label'>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (openOnLoad) {
|
||||
const timeout = setTimeout(() => {
|
||||
ref.current?.querySelectorAll('input')[0]?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [openOnLoad]);
|
||||
|
||||
return (
|
||||
<FieldsetWithStatus
|
||||
{...props}
|
||||
tagOptions={convertAlbumsToAnnotatedTags(albumOptions)}
|
||||
tagOptionsShouldParameterize={false}
|
||||
/>
|
||||
<div ref={ref}>
|
||||
<FieldsetWithStatus
|
||||
{...props}
|
||||
label={label ?? 'Albums'}
|
||||
tagOptions={convertAlbumsToAnnotatedTags(albumOptions)}
|
||||
tagOptionsShouldParameterize={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,14 +6,20 @@ import EntityLink, { EntityLinkExternalProps } from
|
||||
import IconAlbum from '@/components/icons/IconAlbum';
|
||||
import { Album } from '.';
|
||||
import useCategoryCounts from '@/category/useCategoryCounts';
|
||||
import AdminAlbumMenu from './AdminAlbumMenu';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
|
||||
export default function PhotoAlbum({
|
||||
album,
|
||||
showAdminMenu,
|
||||
...props
|
||||
}: {
|
||||
album: Album
|
||||
showAdminMenu?: boolean
|
||||
} & EntityLinkExternalProps) {
|
||||
const { getAlbumCount } = useCategoryCounts();
|
||||
const { isUserSignedIn } = useAppState();
|
||||
const count = props.hoverCount ?? getAlbumCount(album);
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
@ -22,6 +28,8 @@ export default function PhotoAlbum({
|
||||
hoverQueryOptions={{ album }}
|
||||
icon={<IconAlbum className="translate-y-[-0.5px]" />}
|
||||
hoverCount={props.hoverCount ?? getAlbumCount(album)}
|
||||
action={showAdminMenu && isUserSignedIn &&
|
||||
<AdminAlbumMenu {...{ album, count }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
||||
import { deleteAlbum, updateAlbum } from './query';
|
||||
import { addPhotoAlbumIds, deleteAlbum, updateAlbum } from './query';
|
||||
import { revalidateAllKeysAndPaths } from '@/photo/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { PATH_ADMIN_ALBUMS } from '@/app/path';
|
||||
import { PATH_ADMIN_ALBUMS, PATH_ROOT, pathForAlbum } from '@/app/path';
|
||||
import { convertFormDataToAlbum } from './form';
|
||||
import { Album } from '.';
|
||||
import { createAlbumsAndGetIds } from './server';
|
||||
|
||||
export const updateAlbumAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
@ -15,9 +17,31 @@ export const updateAlbumAction = async (formData: FormData) =>
|
||||
redirect(PATH_ADMIN_ALBUMS);
|
||||
});
|
||||
|
||||
export const deleteAlbumAction = async (formData: FormData) =>
|
||||
export const deleteAlbumFormAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const albumId = formData.get('album') as string;
|
||||
await deleteAlbum(albumId);
|
||||
revalidateAllKeysAndPaths();
|
||||
});
|
||||
|
||||
export const deleteAlbumAction = async (
|
||||
album: Album,
|
||||
currentPath?: string,
|
||||
) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
await deleteAlbum(album.id);
|
||||
revalidateAllKeysAndPaths();
|
||||
if (currentPath === pathForAlbum(album)) {
|
||||
redirect(PATH_ROOT);
|
||||
}
|
||||
});
|
||||
|
||||
export const addPhotosToAlbumsAction = async (
|
||||
photoIds: string[],
|
||||
albumTitles: string[],
|
||||
) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const albumIds = await createAlbumsAndGetIds(albumTitles);
|
||||
await addPhotoAlbumIds(photoIds, albumIds);
|
||||
revalidateAllKeysAndPaths();
|
||||
});
|
||||
|
||||
@ -31,6 +31,13 @@ export type AlbumOrAlbumSlug = Album | string;
|
||||
export const parseAlbumFromDb = (album: any): Album =>
|
||||
camelcaseKeys(album);
|
||||
|
||||
export const albumHasMeta = (album: Album) =>
|
||||
album.subhead ||
|
||||
album.description ||
|
||||
album.locationName ||
|
||||
album.latitude ||
|
||||
album.longitude;
|
||||
|
||||
export const titleForAlbum = (
|
||||
album: Album,
|
||||
photos:Photo[] = [],
|
||||
@ -83,3 +90,12 @@ export const generateMetaForAlbum = (
|
||||
),
|
||||
images: absolutePathForAlbumImage(album),
|
||||
});
|
||||
|
||||
export const deleteAlbumConfirmationText = (
|
||||
album: Album,
|
||||
count: number,
|
||||
appText: AppTextState,
|
||||
) =>
|
||||
`Are you sure you want to delete the "${album.title}" album, containing ` +
|
||||
`${photoQuantityText(count, appText, false, false).toLowerCase()}? ` +
|
||||
'No photos will be deleted.';
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { safelyQuery } from '@/db/query';
|
||||
import { sql } from '@/platforms/postgres';
|
||||
import { query, sql } from '@/platforms/postgres';
|
||||
import { generateManyToManyValues } from '@/db';
|
||||
import { Album, Albums, parseAlbumFromDb } from '.';
|
||||
|
||||
export const createAlbumsTable = () =>
|
||||
@ -100,11 +101,22 @@ export const clearPhotoAlbumIds = (photoId: string) =>
|
||||
DELETE FROM album_photo WHERE photo_id=${photoId}
|
||||
`, 'clearPhotoAlbumIds');
|
||||
|
||||
export const addPhotoAlbumIds = (photoIds: string[], albumIds: string[]) => {
|
||||
if (photoIds.length > 0 && albumIds.length > 0) {
|
||||
const {
|
||||
valueString,
|
||||
values,
|
||||
} = generateManyToManyValues(albumIds, photoIds);
|
||||
return safelyQuery(() => query(`
|
||||
INSERT INTO album_photo (album_id, photo_id)
|
||||
${valueString}
|
||||
ON CONFLICT (album_id, photo_id) DO NOTHING
|
||||
`, values), 'updateAlbumPhoto');
|
||||
}
|
||||
};
|
||||
|
||||
export const addPhotoAlbumId = (photoId: string, albumId: string) =>
|
||||
safelyQuery(() => sql`
|
||||
INSERT INTO album_photo (album_id, photo_id) VALUES (${albumId}, ${photoId})
|
||||
ON CONFLICT (album_id, photo_id) DO NOTHING
|
||||
`, 'updateAlbumPhoto');
|
||||
addPhotoAlbumIds([photoId], [albumId]);
|
||||
|
||||
export const getAlbumTitlesForPhoto = (photoId: string) =>
|
||||
safelyQuery(() => sql<{ title: string }>`
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { capitalize, capitalizeWords, parameterize } from '@/utility/string';
|
||||
import {
|
||||
addPhotoAlbumId,
|
||||
clearPhotoAlbumIds,
|
||||
getAlbumsWithMeta,
|
||||
insertAlbum,
|
||||
} from './query';
|
||||
import { deletePhotoTagGlobally, getPhotos } from '@/photo/query';
|
||||
|
||||
const createAlbumsAndGetIds = async (titles: string[]) => {
|
||||
export const createAlbumsAndGetIds = async (titles: string[]) => {
|
||||
const albums = await getAlbumsWithMeta();
|
||||
return Promise.all(titles.map(async title => {
|
||||
const album = albums.find(({ album }) => album.title === title);
|
||||
@ -28,3 +29,21 @@ export const addAlbumTitlesToPhoto = async (
|
||||
if (shouldClearPhotoAlbumIds) { await clearPhotoAlbumIds(photoId); }
|
||||
await Promise.all(albumIds.map(albumId => addPhotoAlbumId(photoId, albumId)));
|
||||
};
|
||||
|
||||
export const upgradeTagToAlbum = async (tag: string) => {
|
||||
const title = capitalizeWords(tag.replaceAll('-', ' '));
|
||||
const slug = tag;
|
||||
const photos = await getPhotos({ tag });
|
||||
if (photos.length > 0) {
|
||||
const albumId = await insertAlbum({ title, slug });
|
||||
if (albumId) {
|
||||
return Promise
|
||||
.all(photos.map(photo => addPhotoAlbumId(photo.id, albumId)))
|
||||
.then(() => deletePhotoTagGlobally(tag))
|
||||
.then(() => albumId);
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(`Failed to upgrade tag "${tag}" to album`),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -5,7 +5,7 @@ import { Camera } from '@/camera';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { TAG_PRIVATE } from '@/tag';
|
||||
import { Lens } from '@/lens';
|
||||
import { Album, AlbumOrAlbumSlug } from '@/album';
|
||||
import { AlbumOrAlbumSlug } from '@/album';
|
||||
|
||||
// Core
|
||||
export const PATH_ROOT = '/';
|
||||
@ -130,6 +130,16 @@ type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory & {
|
||||
showRecipe?: boolean
|
||||
};
|
||||
|
||||
const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
|
||||
typeof photoOrPhotoId === 'string'
|
||||
? photoOrPhotoId
|
||||
: photoOrPhotoId.id;
|
||||
|
||||
const getAlbumSlug = (albumOrAlbumSlug: AlbumOrAlbumSlug) =>
|
||||
typeof albumOrAlbumSlug === 'string'
|
||||
? albumOrAlbumSlug
|
||||
: albumOrAlbumSlug.slug;
|
||||
|
||||
export const pathForAdminUploadUrl = (url: string, title?: string) =>
|
||||
// eslint-disable-next-line max-len
|
||||
`${PATH_ADMIN_UPLOADS}/${encodeURIComponent(url)}${title ? `?${PARAM_UPLOAD_TITLE}=${encodeURIComponent(title)}` : ''}`;
|
||||
@ -137,8 +147,8 @@ export const pathForAdminUploadUrl = (url: string, title?: string) =>
|
||||
export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) =>
|
||||
`${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/${EDIT}`;
|
||||
|
||||
export const pathForAdminAlbumEdit = (album: Album) =>
|
||||
`${PATH_ADMIN_ALBUMS}/${album.slug}/${EDIT}`;
|
||||
export const pathForAdminAlbumEdit = (album: AlbumOrAlbumSlug) =>
|
||||
`${PATH_ADMIN_ALBUMS}/${getAlbumSlug(album)}/${EDIT}`;
|
||||
|
||||
export const pathForAdminTagEdit = (tag: string) =>
|
||||
`${PATH_ADMIN_TAGS}/${tag}/${EDIT}`;
|
||||
@ -148,9 +158,6 @@ export const pathForAdminRecipeEdit = (recipe: string) =>
|
||||
|
||||
type PhotoOrPhotoId = Photo | string;
|
||||
|
||||
const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
|
||||
typeof photoOrPhotoId === 'string' ? photoOrPhotoId : photoOrPhotoId.id;
|
||||
|
||||
export const pathForPhoto = ({
|
||||
photo,
|
||||
recent,
|
||||
@ -202,7 +209,7 @@ export const pathForLens = ({ make, model }: Lens) =>
|
||||
: `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`;
|
||||
|
||||
export const pathForAlbum = (album: AlbumOrAlbumSlug) =>
|
||||
`${PREFIX_ALBUM}/${typeof album === 'string' ? album : album.slug}`;
|
||||
`${PREFIX_ALBUM}/${getAlbumSlug(album)}`;
|
||||
|
||||
export const pathForTag = (tag: string) =>
|
||||
`${PREFIX_TAG}/${tag}`;
|
||||
@ -229,7 +236,7 @@ export const pathForCameraImage = (camera: Camera) =>
|
||||
export const pathForLensImage = (lens: Lens) =>
|
||||
pathForImage(pathForLens(lens));
|
||||
|
||||
export const pathForAlbumImage = (album: Album) =>
|
||||
export const pathForAlbumImage = (album: AlbumOrAlbumSlug) =>
|
||||
pathForImage(pathForAlbum(album));
|
||||
|
||||
export const pathForTagImage = (tag: string) =>
|
||||
@ -278,7 +285,10 @@ export const absolutePathForCamera= (camera: Camera, share?: boolean) =>
|
||||
export const absolutePathForLens= (lens: Lens, share?: boolean) =>
|
||||
`${getBaseUrl(share)}${pathForLens(lens)}`;
|
||||
|
||||
export const absolutePathForAlbum = (album: Album, share?: boolean) =>
|
||||
export const absolutePathForAlbum = (
|
||||
album: AlbumOrAlbumSlug,
|
||||
share?: boolean,
|
||||
) =>
|
||||
`${getBaseUrl(share)}${pathForAlbum(album)}`;
|
||||
|
||||
export const absolutePathForTag = (tag: string, share?: boolean) =>
|
||||
@ -308,7 +318,7 @@ export const absolutePathForCameraImage= (camera: Camera) =>
|
||||
export const absolutePathForLensImage= (lens: Lens) =>
|
||||
`${absolutePathForLens(lens)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForAlbumImage = (album: Album) =>
|
||||
export const absolutePathForAlbumImage = (album: AlbumOrAlbumSlug) =>
|
||||
`${absolutePathForAlbum(album)}/${IMAGE}`;
|
||||
|
||||
export const absolutePathForTagImage = (tag: string) =>
|
||||
|
||||
@ -36,7 +36,7 @@ export default function FieldsetWithStatus({
|
||||
placeholder,
|
||||
loading,
|
||||
required,
|
||||
readOnly,
|
||||
readOnly: readOnlyProp,
|
||||
spellCheck,
|
||||
capitalize,
|
||||
type = 'text',
|
||||
@ -86,6 +86,8 @@ export default function FieldsetWithStatus({
|
||||
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
const readOnly = readOnlyProp || pending || loading;
|
||||
|
||||
const inputProps: InputHTMLAttributes<HTMLInputElement> = {
|
||||
id,
|
||||
name: id,
|
||||
@ -101,7 +103,7 @@ export default function FieldsetWithStatus({
|
||||
spellCheck,
|
||||
autoComplete: 'off',
|
||||
autoCapitalize: !capitalize ? 'off' : undefined,
|
||||
readOnly: readOnly || pending || loading,
|
||||
readOnly,
|
||||
disabled: type === 'checkbox' && (
|
||||
readOnly || pending || loading
|
||||
),
|
||||
@ -167,7 +169,7 @@ export default function FieldsetWithStatus({
|
||||
{isModified && !error &&
|
||||
<span className={clsx(
|
||||
'text-main font-medium text-[0.9rem]',
|
||||
' -ml-1.5 translate-y-[-1px]',
|
||||
' -ml-1.5 translate-y-[-1px] -z-1',
|
||||
)}>
|
||||
*
|
||||
</span>}
|
||||
@ -196,7 +198,7 @@ export default function FieldsetWithStatus({
|
||||
options={selectOptions}
|
||||
defaultOptionLabel={selectOptionsDefaultLabel}
|
||||
error={error}
|
||||
readOnly={readOnly || pending || loading}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
: tagOptions
|
||||
? <TagInput
|
||||
@ -208,7 +210,7 @@ export default function FieldsetWithStatus({
|
||||
onChange={onChange}
|
||||
showMenuOnDelete={tagOptionsLimit === 1}
|
||||
className={clsx(Boolean(error) && 'error')}
|
||||
readOnly={readOnly || pending || loading}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
limit={tagOptionsLimit}
|
||||
limitValidationMessage={tagOptionsLimitValidationMessage}
|
||||
@ -221,7 +223,7 @@ export default function FieldsetWithStatus({
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
readOnly={readOnly || pending || loading}
|
||||
readOnly={readOnly}
|
||||
spellCheck={spellCheck}
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
className={clsx(
|
||||
@ -235,6 +237,7 @@ export default function FieldsetWithStatus({
|
||||
accessory={loading && <Spinner
|
||||
className="translate-y-[0.5px]"
|
||||
/>}
|
||||
readOnly={readOnly}
|
||||
{...inputProps}
|
||||
/>
|
||||
: <input
|
||||
|
||||
9
src/components/icons/IconTrash.tsx
Normal file
9
src/components/icons/IconTrash.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
import { IconBaseProps } from 'react-icons/lib';
|
||||
|
||||
export default function IconTrash(props: IconBaseProps) {
|
||||
return <BiTrash
|
||||
{...props}
|
||||
size={props.size ?? 15}
|
||||
/>;
|
||||
}
|
||||
@ -73,6 +73,7 @@ export default function MoreMenu({
|
||||
'text-dim',
|
||||
'outline-none',
|
||||
classNameButton,
|
||||
isOpen && 'bg-dim',
|
||||
isOpen && classNameButtonOpen,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
@ -94,8 +95,10 @@ export default function MoreMenu({
|
||||
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
|
||||
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
|
||||
'data-[side=top]:animate-fade-in-from-bottom',
|
||||
'data-[side=bottom]:animate-fade-in-from-top',
|
||||
'data-[side=right]:animate-fade-in-from-top',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@ -218,3 +218,33 @@ export const getLimitAndOffsetFromOptions = (
|
||||
limitAndOffsetValues: [limit, offset],
|
||||
};
|
||||
};
|
||||
|
||||
export const convertArrayToPostgresString = (
|
||||
array?: string[],
|
||||
type: 'braces' | 'brackets' | 'parentheses' = 'braces',
|
||||
) => array
|
||||
? type === 'braces'
|
||||
? `{${array.join(',')}}`
|
||||
: type === 'brackets'
|
||||
? `[${array.map(i => `'${i}'`).join(',')}]`
|
||||
: `(${array.map(i => `'${i}'`).join(',')})`
|
||||
: null;
|
||||
|
||||
export const generateManyToManyValues = (idsA: string[], idsB: string[]) => {
|
||||
const pairs: string[][] = [];
|
||||
|
||||
for (const idA of idsA) {
|
||||
for (const idB of idsB) {
|
||||
pairs.push([idA, idB]);
|
||||
}
|
||||
}
|
||||
const valueString = 'VALUES ' + pairs.map((_, index) =>
|
||||
`($${index * 2 + 1},$${index * 2 + 2})`).join(',');
|
||||
|
||||
const values = pairs.flat();
|
||||
|
||||
return {
|
||||
valueString,
|
||||
values,
|
||||
};
|
||||
};
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
PATH_ADMIN_TAGS,
|
||||
PATH_ROOT,
|
||||
pathForPhoto,
|
||||
pathForTag,
|
||||
} from '@/app/path';
|
||||
import {
|
||||
blurImageFromUrl,
|
||||
@ -68,7 +69,12 @@ import {
|
||||
} from '@/photo/color/server';
|
||||
import { shouldBackfillPhotoStorage } from './update/server';
|
||||
import { getAlbumTitlesFromFormData } from '@/album/form';
|
||||
import { addAlbumTitlesToPhoto } from '@/album/server';
|
||||
import {
|
||||
addAlbumTitlesToPhoto,
|
||||
createAlbumsAndGetIds,
|
||||
upgradeTagToAlbum,
|
||||
} from '@/album/server';
|
||||
import { addPhotoAlbumIds } from '@/album/query';
|
||||
|
||||
// Private actions
|
||||
|
||||
@ -102,6 +108,7 @@ export const createPhotoAction = async (formData: FormData) =>
|
||||
const addUpload = async ({
|
||||
url,
|
||||
title: _title,
|
||||
albumIds = [],
|
||||
tags: _tags,
|
||||
favorite,
|
||||
hidden,
|
||||
@ -114,6 +121,7 @@ const addUpload = async ({
|
||||
}:{
|
||||
url: string
|
||||
title?: string
|
||||
albumIds?: string[]
|
||||
tags?: string
|
||||
favorite?: string
|
||||
hidden?: string
|
||||
@ -190,6 +198,9 @@ const addUpload = async ({
|
||||
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(form);
|
||||
photo.url = updatedUrl;
|
||||
await insertPhoto(photo);
|
||||
if (albumIds.length > 0) {
|
||||
await addPhotoAlbumIds([photo.id], albumIds);
|
||||
}
|
||||
if (shouldRevalidateAllKeysAndPaths) {
|
||||
after(revalidateAllKeysAndPaths);
|
||||
}
|
||||
@ -207,6 +218,7 @@ export const addUploadsAction = async ({
|
||||
uploadUrls,
|
||||
uploadTitles,
|
||||
shouldRevalidateAllKeysAndPaths = true,
|
||||
albumTitles,
|
||||
tags,
|
||||
favorite,
|
||||
hidden,
|
||||
@ -215,11 +227,12 @@ export const addUploadsAction = async ({
|
||||
takenAtNaiveLocal,
|
||||
}: Omit<
|
||||
Parameters<typeof addUpload>[0],
|
||||
'url' | 'onStreamUpdate' | 'onFinish'
|
||||
'url' | 'onStreamUpdate' | 'onFinish' | 'albumIds'
|
||||
> & {
|
||||
uploadUrls: string[]
|
||||
uploadTitles: string[]
|
||||
shouldRevalidateAllKeysAndPaths?: boolean
|
||||
albumTitles?: string[]
|
||||
}) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const PROGRESS_TASK_COUNT = AI_CONTENT_GENERATION_ENABLED ? 5 : 4;
|
||||
@ -241,6 +254,10 @@ export const addUploadsAction = async ({
|
||||
progress: ++progress / PROGRESS_TASK_COUNT,
|
||||
});
|
||||
|
||||
const albumIds = albumTitles
|
||||
? await createAlbumsAndGetIds(albumTitles)
|
||||
: [];
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for (const [index, url] of uploadUrls.entries()) {
|
||||
@ -252,6 +269,7 @@ export const addUploadsAction = async ({
|
||||
await addUpload({
|
||||
url,
|
||||
title,
|
||||
albumIds,
|
||||
tags,
|
||||
favorite,
|
||||
hidden,
|
||||
@ -380,16 +398,26 @@ export const deletePhotoAction = async (
|
||||
}
|
||||
});
|
||||
|
||||
export const deletePhotoTagGloballyAction = async (formData: FormData) =>
|
||||
export const deletePhotoTagGloballyFormAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const tag = formData.get('tag') as string;
|
||||
|
||||
await deletePhotoTagGlobally(tag);
|
||||
|
||||
revalidatePhotosKey();
|
||||
revalidateAdminPaths();
|
||||
});
|
||||
|
||||
export const deletePhotoTagGloballyAction = async (
|
||||
tag: string,
|
||||
currentPath?: string,
|
||||
) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
await deletePhotoTagGlobally(tag);
|
||||
revalidateAllKeysAndPaths();
|
||||
if (currentPath === pathForTag(tag)) {
|
||||
redirect(PATH_ROOT);
|
||||
}
|
||||
});
|
||||
|
||||
export const renamePhotoTagGloballyAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const tag = formData.get('tag') as string;
|
||||
@ -403,6 +431,11 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
|
||||
}
|
||||
});
|
||||
|
||||
export const upgradeTagToAlbumAction = async (tag: string) =>
|
||||
runAuthenticatedAdminServerAction(async () =>
|
||||
upgradeTagToAlbum(tag).then(revalidateAllKeysAndPaths),
|
||||
);
|
||||
|
||||
export const getPhotosNeedingRecipeTitleCountAction = async (
|
||||
recipeData: string,
|
||||
film: string,
|
||||
|
||||
@ -597,7 +597,7 @@ export default function PhotoForm({
|
||||
isModified={areAlbumTitlesModified}
|
||||
className={clsx(
|
||||
fieldProps.className,
|
||||
'relative z-10',
|
||||
'relative z-1',
|
||||
)}
|
||||
/>;
|
||||
case 'visibility':
|
||||
@ -631,6 +631,7 @@ export default function PhotoForm({
|
||||
<div className={clsx(
|
||||
'flex gap-3 sticky bottom-0',
|
||||
'pb-4 md:pb-8 mt-16',
|
||||
'relative z-10',
|
||||
)}>
|
||||
<Link
|
||||
className="button"
|
||||
|
||||
@ -250,7 +250,7 @@ export const photoLabelForCount = (
|
||||
: appText.photo.photoPlural;
|
||||
return _capitalize
|
||||
? capitalize(label)
|
||||
: label;
|
||||
: label.toLocaleLowerCase();
|
||||
};
|
||||
|
||||
export const photoQuantityText = (
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
import {
|
||||
sql,
|
||||
query,
|
||||
convertArrayToPostgresString,
|
||||
} from '@/platforms/postgres';
|
||||
import { convertArrayToPostgresString } from '@/db';
|
||||
import {
|
||||
PhotoDb,
|
||||
PhotoDbInsert,
|
||||
|
||||
@ -47,17 +47,6 @@ export const sql = <T extends QueryResultRow>(
|
||||
return query<T>(result, values);
|
||||
};
|
||||
|
||||
export const convertArrayToPostgresString = (
|
||||
array?: string[],
|
||||
type: 'braces' | 'brackets' | 'parentheses' = 'braces',
|
||||
) => array
|
||||
? type === 'braces'
|
||||
? `{${array.join(',')}}`
|
||||
: type === 'brackets'
|
||||
? `[${array.map(i => `'${i}'`).join(',')}]`
|
||||
: `(${array.map(i => `'${i}'`).join(',')})`
|
||||
: null;
|
||||
|
||||
const isTemplateStringsArray = (
|
||||
strings: unknown,
|
||||
): strings is TemplateStringsArray => {
|
||||
|
||||
74
src/tag/AdminTagMenu.tsx
Normal file
74
src/tag/AdminTagMenu.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import MoreMenu from '@/components/more/MoreMenu';
|
||||
import { TbFolderUp } from 'react-icons/tb';
|
||||
import { deleteTagConfirmationText, formatTag } from '.';
|
||||
import {
|
||||
deletePhotoTagGloballyAction,
|
||||
upgradeTagToAlbumAction,
|
||||
} from '@/photo/actions';
|
||||
import { toastSuccess } from '@/toast';
|
||||
import { pathForAdminAlbumEdit, pathForAdminTagEdit } from '@/app/path';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import IconEdit from '@/components/icons/IconEdit';
|
||||
import IconTrash from '@/components/icons/IconTrash';
|
||||
|
||||
export default function AdminTagMenu({
|
||||
tag,
|
||||
count,
|
||||
}: {
|
||||
tag: string
|
||||
count: number
|
||||
}) {
|
||||
const appText = useAppText();
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<MoreMenu
|
||||
ariaLabel="Tag menu"
|
||||
className="m-2"
|
||||
classNameButton="h-3.5 translate-y-1"
|
||||
side="right"
|
||||
sections={[{
|
||||
items: [{
|
||||
label: 'Edit',
|
||||
icon: <IconEdit
|
||||
size={15}
|
||||
className="translate-y-[0.5px]"
|
||||
/>,
|
||||
href: pathForAdminTagEdit(tag),
|
||||
}, {
|
||||
icon: <TbFolderUp
|
||||
size={16}
|
||||
className="translate-x-[-1px]"
|
||||
/>,
|
||||
label: 'Upgrade',
|
||||
action: () => {
|
||||
// eslint-disable-next-line max-len
|
||||
if (confirm(`Are you sure you want to upgrade "${formatTag(tag)}" to an album?`)) {
|
||||
return upgradeTagToAlbumAction(tag)
|
||||
.then(() => {
|
||||
toastSuccess(`"${formatTag(tag)}" upgraded to album`);
|
||||
router.push(pathForAdminAlbumEdit(tag));
|
||||
});
|
||||
}
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
items: [{
|
||||
icon: <IconTrash
|
||||
className="translate-x-[-1px]"
|
||||
/>,
|
||||
label: 'Delete',
|
||||
className: 'text-error *:hover:text-error',
|
||||
color: 'red',
|
||||
action: () => {
|
||||
if (confirm(deleteTagConfirmationText(tag, count, appText))) {
|
||||
return deletePhotoTagGloballyAction(tag, path);
|
||||
}
|
||||
},
|
||||
}],
|
||||
}]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { useAppText } from '@/i18n/state/client';
|
||||
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
|
||||
import { ComponentProps, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function PhotoTagFieldset(props: {
|
||||
export default function FieldsetTag(props: {
|
||||
tags: string
|
||||
tagOptions?: Tags
|
||||
onChange: (tags: string) => void
|
||||
@ -24,7 +24,7 @@ export default function PhotoTagFieldset(props: {
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const appText = useAppText();
|
||||
|
||||
@ -43,7 +43,6 @@ export default function PhotoTagFieldset(props: {
|
||||
<div ref={ref}>
|
||||
<FieldsetWithStatus
|
||||
{...rest}
|
||||
inputRef={ref}
|
||||
label="Tags"
|
||||
value={tags}
|
||||
tagOptions={convertTagsForForm(tagOptions, appText)}
|
||||
@ -7,14 +7,20 @@ import EntityLink, {
|
||||
} from '@/components/entity/EntityLink';
|
||||
import IconTag from '@/components/icons/IconTag';
|
||||
import useCategoryCounts from '@/category/useCategoryCounts';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import AdminTagMenu from './AdminTagMenu';
|
||||
|
||||
export default function PhotoTag({
|
||||
tag,
|
||||
showAdminMenu,
|
||||
...props
|
||||
}: {
|
||||
tag: string
|
||||
showAdminMenu?: boolean
|
||||
} & EntityLinkExternalProps) {
|
||||
const { getTagCount } = useCategoryCounts();
|
||||
const { isUserSignedIn } = useAppState();
|
||||
const count = props.hoverCount ?? getTagCount(tag);
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
@ -22,7 +28,9 @@ export default function PhotoTag({
|
||||
path={pathForTag(tag)}
|
||||
hoverQueryOptions={{ tag }}
|
||||
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
|
||||
hoverCount={props.hoverCount ?? getTagCount(tag)}
|
||||
hoverCount={count}
|
||||
action={showAdminMenu && isUserSignedIn &&
|
||||
<AdminTagMenu {...{ tag, count }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ export default async function TagHeader({
|
||||
tag={tag}
|
||||
contrast="high"
|
||||
hoverType="none"
|
||||
showAdminMenu
|
||||
/>}
|
||||
entityVerb={appText.category.tagged}
|
||||
entityDescription={descriptionForTaggedPhotos(
|
||||
|
||||
@ -128,6 +128,14 @@ export const generateMetaForTag = (
|
||||
images: absolutePathForTagImage(tag),
|
||||
});
|
||||
|
||||
export const deleteTagConfirmationText = (
|
||||
tag: string,
|
||||
count: number,
|
||||
appText: AppTextState,
|
||||
) =>
|
||||
// eslint-disable-next-line max-len
|
||||
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`;
|
||||
|
||||
export const isTagFavs = (tag: string) => tag.toLocaleLowerCase() === TAG_FAVS;
|
||||
|
||||
export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
||||
|
||||
@ -266,6 +266,8 @@ html {
|
||||
text-medium
|
||||
bg-gray-100 dark:bg-gray-900
|
||||
pointer-events-none
|
||||
read-only:bg-gray-100
|
||||
dark:read-only:bg-gray-900 dark:read-only:text-gray-400
|
||||
}
|
||||
input[type=file] {
|
||||
@apply
|
||||
|
||||
Loading…
Reference in New Issue
Block a user