From ee9f3f4dc24fa6300aeb86a27e40388b58cfb02e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 18 Sep 2025 22:41:12 -0500 Subject: [PATCH] Album upgrades (#326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- __tests__/postgres.test.ts | 17 +++ app/admin/components/page.tsx | 4 +- app/admin/uploads/page.tsx | 3 + src/admin/AdminAlbumsTable.tsx | 11 +- src/admin/AdminBatchUploadActions.tsx | 19 +++- src/admin/AdminPhotoMenu.tsx | 6 +- src/admin/AdminTagsTable.tsx | 11 +- src/admin/AdminUploadsClient.tsx | 6 +- src/admin/select/AdminBatchEditPanel.tsx | 8 +- .../select/AdminBatchEditPanelClient.tsx | 103 ++++++++++++------ src/album/AdminAlbumMenu.tsx | 52 +++++++++ src/album/AlbumHeader.tsx | 5 +- src/album/FieldsetAlbum.tsx | 32 ++++-- src/album/PhotoAlbum.tsx | 8 ++ src/album/actions.ts | 30 ++++- src/album/index.ts | 16 +++ src/album/query.ts | 22 +++- src/album/server.ts | 23 +++- src/app/path.ts | 30 +++-- src/components/FieldsetWithStatus.tsx | 15 ++- src/components/icons/IconTrash.tsx | 9 ++ src/components/more/MoreMenu.tsx | 3 + src/db/index.ts | 30 +++++ src/photo/actions.ts | 43 +++++++- src/photo/form/PhotoForm.tsx | 3 +- src/photo/index.ts | 2 +- src/photo/query.ts | 2 +- src/platforms/postgres.ts | 11 -- src/tag/AdminTagMenu.tsx | 74 +++++++++++++ .../FieldsetTag.tsx} | 5 +- src/tag/PhotoTag.tsx | 10 +- src/tag/TagHeader.tsx | 1 + src/tag/index.ts | 8 ++ tailwind.css | 2 + 34 files changed, 510 insertions(+), 114 deletions(-) create mode 100644 __tests__/postgres.test.ts create mode 100644 src/album/AdminAlbumMenu.tsx create mode 100644 src/components/icons/IconTrash.tsx create mode 100644 src/tag/AdminTagMenu.tsx rename src/{admin/PhotoTagFieldset.tsx => tag/FieldsetTag.tsx} (92%) diff --git a/__tests__/postgres.test.ts b/__tests__/postgres.test.ts new file mode 100644 index 00000000..067189d6 --- /dev/null +++ b/__tests__/postgres.test.ts @@ -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'], + }); + }); +}); diff --git a/app/admin/components/page.tsx b/app/admin/components/page.tsx index 397353a6..5df02c44 100644 --- a/app/admin/components/page.tsx +++ b/app/admin/components/page.tsx @@ -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() {
- } /> diff --git a/src/admin/AdminAlbumsTable.tsx b/src/admin/AdminAlbumsTable.tsx index ce9b0953..7553e053 100644 --- a/src/admin/AdminAlbumsTable.tsx +++ b/src/admin/AdminAlbumsTable.tsx @@ -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({ )}> diff --git a/src/admin/AdminBatchUploadActions.tsx b/src/admin/AdminBatchUploadActions.tsx index 0084cd85..389d801e 100644 --- a/src/admin/AdminBatchUploadActions.tsx +++ b/src/admin/AdminBatchUploadActions.tsx @@ -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> @@ -54,6 +58,7 @@ export default function AdminBatchUploadActions({ const [showBulkSettings, setShowBulkSettings] = useState(false); const [tagErrorMessage, setTagErrorMessage] = useState(''); const [formData, setFormData] = useState>({}); + const [albumTitles, setAlbumTitles] = useState(); 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 && {actionErrorMessage}} - +
@@ -146,7 +152,14 @@ export default function AdminBatchUploadActions({
{showBulkSettings && !actionErrorMessage &&
- setAlbumTitles(albums)} + readOnly={isAdding} + className="relative z-11" + /> + { const items: ComponentProps[] = [{ label: appText.admin.edit, @@ -154,8 +153,7 @@ export default function AdminPhotoMenu({ const sectionDelete: MoreMenuSection = useMemo(() => ({ items: [{ label: appText.admin.delete, - icon: , className: 'text-error *:hover:text-error', diff --git a/src/admin/AdminTagsTable.tsx b/src/admin/AdminTagsTable.tsx index 3483bb84..fa749b9d 100644 --- a/src/admin/AdminTagsTable.tsx +++ b/src/admin/AdminTagsTable.tsx @@ -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({ )}> diff --git a/src/admin/AdminUploadsClient.tsx b/src/admin/AdminUploadsClient.tsx index b6a8c290..3a043742 100644 --- a/src/admin/AdminUploadsClient.tsx +++ b/src/admin/AdminUploadsClient.tsx @@ -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(urls); @@ -41,6 +44,7 @@ export default function AdminUploadsClient({ Promise }) { + const uniqueAlbums = await getAlbumsWithMeta().catch(() => []); const uniqueTags = await getUniqueTagsCached().catch(() => []); return ( - + ); } diff --git a/src/admin/select/AdminBatchEditPanelClient.tsx b/src/admin/select/AdminBatchEditPanelClient.tsx index 24f62bb1..6fd07173 100644 --- a/src/admin/select/AdminBatchEditPanelClient.tsx +++ b/src/admin/select/AdminBatchEditPanelClient.tsx @@ -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(null); @@ -37,6 +43,9 @@ export default function AdminBatchEditPanelClient({ const appText = useAppText(); + const [albumTitles, setAlbumsTitles] = useState(); + const isInAlbumMode = albumTitles !== undefined; + const [tags, setTags] = useState(); const [tagErrorMessage, setTagErrorMessage] = useState(''); const isInTagMode = tags !== undefined; @@ -55,7 +64,7 @@ export default function AdminBatchEditPanelClient({ const renderPhotoCTA = selectedPhotoIds?.length === 0 ? <> - + Select photos below @@ -63,7 +72,7 @@ export default function AdminBatchEditPanelClient({ {photosText} selected ; - const renderActions = isInTagMode + const renderActions = isInTagMode || isInAlbumMode ? <> } onClick={() => { + setAlbumsTitles(undefined); setTags(undefined); setTagErrorMessage(''); }} disabled={isPerformingSelectEdit} - > - Cancel - + /> } - // 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 : <> @@ -132,12 +157,19 @@ export default function AdminBatchEditPanelClient({ .finally(() => setIsPerformingSelectEdit?.(false)); }} /> + setAlbumsTitles('')} + disabled={isFormDisabled} + icon={} + > + Album + setTags('')} disabled={isFormDisabled} icon={} > - Tag ... + Tag } @@ -178,20 +210,29 @@ export default function AdminBatchEditPanelClient({ spaceChildren={false} hideIcon > - {isInTagMode - ? - :
- {renderPhotoCTA} -
} + : isInTagMode + ? + :
+ {renderPhotoCTA} +
} {tagErrorMessage &&
diff --git a/src/album/AdminAlbumMenu.tsx b/src/album/AdminAlbumMenu.tsx new file mode 100644 index 00000000..785b7662 --- /dev/null +++ b/src/album/AdminAlbumMenu.tsx @@ -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 ( + , + href: pathForAdminAlbumEdit(album), + }], + }, { + items: [{ + icon: , + label: 'Delete', + className: 'text-error *:hover:text-error', + color: 'red', + action: () => { + if (confirm(deleteAlbumConfirmationText(album, count, appText))) { + return deleteAlbumAction(album, path); + } + }, + }], + }]} + /> + ); +} diff --git a/src/album/AlbumHeader.tsx b/src/album/AlbumHeader.tsx index 626497d8..64acefc2 100644 --- a/src/album/AlbumHeader.tsx +++ b/src/album/AlbumHeader.tsx @@ -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) ?
{album.subhead &&
diff --git a/src/album/FieldsetAlbum.tsx b/src/album/FieldsetAlbum.tsx index 894289a6..7fa1843d 100644 --- a/src/album/FieldsetAlbum.tsx +++ b/src/album/FieldsetAlbum.tsx @@ -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) { + label?: string + openOnLoad?: boolean +} & Omit, 'label'>) { + const ref = useRef(null); + + useEffect(() => { + if (openOnLoad) { + const timeout = setTimeout(() => { + ref.current?.querySelectorAll('input')[0]?.focus(); + }, 100); + return () => clearTimeout(timeout); + } + }, [openOnLoad]); + return ( - +
+ +
); } diff --git a/src/album/PhotoAlbum.tsx b/src/album/PhotoAlbum.tsx index 571f4ada..bc1a8b35 100644 --- a/src/album/PhotoAlbum.tsx +++ b/src/album/PhotoAlbum.tsx @@ -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 ( } hoverCount={props.hoverCount ?? getAlbumCount(album)} + action={showAdminMenu && isUserSignedIn && + } /> ); } diff --git a/src/album/actions.ts b/src/album/actions.ts index 95419811..79d3336f 100644 --- a/src/album/actions.ts +++ b/src/album/actions.ts @@ -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(); + }); diff --git a/src/album/index.ts b/src/album/index.ts index e850ac01..2c1c9917 100644 --- a/src/album/index.ts +++ b/src/album/index.ts @@ -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.'; diff --git a/src/album/query.ts b/src/album/query.ts index bd87611a..3252303a 100644 --- a/src/album/query.ts +++ b/src/album/query.ts @@ -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 }>` diff --git a/src/album/server.ts b/src/album/server.ts index 8e55a5a4..1632ee26 100644 --- a/src/album/server.ts +++ b/src/album/server.ts @@ -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`), + ); + } +}; diff --git a/src/app/path.ts b/src/app/path.ts index 5705e449..6fe35d94 100644 --- a/src/app/path.ts +++ b/src/app/path.ts @@ -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) => diff --git a/src/components/FieldsetWithStatus.tsx b/src/components/FieldsetWithStatus.tsx index 2113e9fb..fb28b46a 100644 --- a/src/components/FieldsetWithStatus.tsx +++ b/src/components/FieldsetWithStatus.tsx @@ -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 = { 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 && * } @@ -196,7 +198,7 @@ export default function FieldsetWithStatus({ options={selectOptions} defaultOptionLabel={selectOptionsDefaultLabel} error={error} - readOnly={readOnly || pending || loading} + readOnly={readOnly} /> : tagOptions ? 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 && } + readOnly={readOnly} {...inputProps} /> : ; +} diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index 9fbb4e31..c0cfabb5 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -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, )} > diff --git a/src/db/index.ts b/src/db/index.ts index ec2e304d..abf5e00b 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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, + }; +}; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 8257a2db..fce7ebc7 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -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[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, diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 4bccaddc..dec219ab 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -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({
( return query(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 => { diff --git a/src/tag/AdminTagMenu.tsx b/src/tag/AdminTagMenu.tsx new file mode 100644 index 00000000..e88ce4cc --- /dev/null +++ b/src/tag/AdminTagMenu.tsx @@ -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 ( + , + href: pathForAdminTagEdit(tag), + }, { + icon: , + 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: , + label: 'Delete', + className: 'text-error *:hover:text-error', + color: 'red', + action: () => { + if (confirm(deleteTagConfirmationText(tag, count, appText))) { + return deletePhotoTagGloballyAction(tag, path); + } + }, + }], + }]} + /> + ); +} diff --git a/src/admin/PhotoTagFieldset.tsx b/src/tag/FieldsetTag.tsx similarity index 92% rename from src/admin/PhotoTagFieldset.tsx rename to src/tag/FieldsetTag.tsx index 5c72a409..6b672c58 100644 --- a/src/admin/PhotoTagFieldset.tsx +++ b/src/tag/FieldsetTag.tsx @@ -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(null); + const ref = useRef(null); const appText = useAppText(); @@ -43,7 +43,6 @@ export default function PhotoTagFieldset(props: {
} - hoverCount={props.hoverCount ?? getTagCount(tag)} + hoverCount={count} + action={showAdminMenu && isUserSignedIn && + } /> ); } diff --git a/src/tag/TagHeader.tsx b/src/tag/TagHeader.tsx index addf279a..ddac371c 100644 --- a/src/tag/TagHeader.tsx +++ b/src/tag/TagHeader.tsx @@ -34,6 +34,7 @@ export default async function TagHeader({ tag={tag} contrast="high" hoverType="none" + showAdminMenu />} entityVerb={appText.category.tagged} entityDescription={descriptionForTaggedPhotos( diff --git a/src/tag/index.ts b/src/tag/index.ts index 9046d687..f4be325a 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -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); diff --git a/tailwind.css b/tailwind.css index ce29c2d6..b055adba 100644 --- a/tailwind.css +++ b/tailwind.css @@ -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