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