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:
Sam Becker 2025-09-18 22:41:12 -05:00 committed by GitHub
parent 1e66815a3d
commit ee9f3f4dc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 510 additions and 114 deletions

View 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'],
});
});
});

View File

@ -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',

View File

@ -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,
}} />}
/>

View File

@ -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 />

View File

@ -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}

View File

@ -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',

View File

@ -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 />

View File

@ -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,

View File

@ -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,
}} />
);
}

View File

@ -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">

View 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);
}
},
}],
}]}
/>
);
}

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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 }} />}
/>
);
}

View File

@ -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();
});

View File

@ -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.';

View File

@ -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 }>`

View File

@ -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`),
);
}
};

View File

@ -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) =>

View File

@ -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

View 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}
/>;
}

View File

@ -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,
)}
>

View File

@ -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,
};
};

View File

@ -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,

View File

@ -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"

View File

@ -250,7 +250,7 @@ export const photoLabelForCount = (
: appText.photo.photoPlural;
return _capitalize
? capitalize(label)
: label;
: label.toLocaleLowerCase();
};
export const photoQuantityText = (

View File

@ -2,8 +2,8 @@
import {
sql,
query,
convertArrayToPostgresString,
} from '@/platforms/postgres';
import { convertArrayToPostgresString } from '@/db';
import {
PhotoDb,
PhotoDbInsert,

View File

@ -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
View 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);
}
},
}],
}]}
/>
);
}

View File

@ -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)}

View File

@ -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 }} />}
/>
);
}

View File

@ -34,6 +34,7 @@ export default async function TagHeader({
tag={tag}
contrast="high"
hoverType="none"
showAdminMenu
/>}
entityVerb={appText.category.tagged}
entityDescription={descriptionForTaggedPhotos(

View File

@ -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);

View File

@ -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