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'; 'use client';
import PhotoTagFieldset from '@/admin/PhotoTagFieldset'; import FieldsetTag from '@/tag/FieldsetTag';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import FieldsetWithStatus from '@/components/FieldsetWithStatus'; import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import IconHidden from '@/components/icons/IconHidden'; import IconHidden from '@/components/icons/IconHidden';
@ -25,7 +25,7 @@ export default function ComponentsPage() {
<StatusIcon type="optional" /> <StatusIcon type="optional" />
</div> </div>
<div className="z-12"> <div className="z-12">
<PhotoTagFieldset <FieldsetTag
tags="tag-1" tags="tag-1"
tagOptions={[{ tagOptions={[{
tag: 'Tag 1', tag: 'Tag 1',

View File

@ -4,11 +4,13 @@ import { getUniqueTagsCached } from '@/photo/cache';
import AdminUploadsClient from '@/admin/AdminUploadsClient'; import AdminUploadsClient from '@/admin/AdminUploadsClient';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { PATH_ADMIN_PHOTOS } from '@/app/path'; import { PATH_ADMIN_PHOTOS } from '@/app/path';
import { getAlbumsWithMeta } from '@/album/query';
export const maxDuration = 60; export const maxDuration = 60;
export default async function AdminUploadsPage() { export default async function AdminUploadsPage() {
const urls = await getStorageUploadUrlsNoStore(); const urls = await getStorageUploadUrlsNoStore();
const uniqueAlbums = await getAlbumsWithMeta();
const uniqueTags = await getUniqueTagsCached(); const uniqueTags = await getUniqueTagsCached();
if (urls.length === 0) { if (urls.length === 0) {
@ -19,6 +21,7 @@ export default async function AdminUploadsPage() {
contentMain={ contentMain={
<AdminUploadsClient {...{ <AdminUploadsClient {...{
urls, urls,
uniqueAlbums,
uniqueTags, uniqueTags,
}} />} }} />}
/> />

View File

@ -2,14 +2,13 @@ import FormWithConfirm from '@/components/FormWithConfirm';
import AdminTable from '@/admin/AdminTable'; import AdminTable from '@/admin/AdminTable';
import { Fragment } from 'react'; import { Fragment } from 'react';
import DeleteFormButton from '@/admin/DeleteFormButton'; import DeleteFormButton from '@/admin/DeleteFormButton';
import { photoQuantityText } from '@/photo';
import EditButton from '@/admin/EditButton'; import EditButton from '@/admin/EditButton';
import { pathForAdminAlbumEdit } from '@/app/path'; import { pathForAdminAlbumEdit } from '@/app/path';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { getAppText } from '@/i18n/state/server'; import { getAppText } from '@/i18n/state/server';
import { Albums } from '@/album'; import { Albums, deleteAlbumConfirmationText } from '@/album';
import AdminAlbumBadge from './AdminAlbumBadge'; import AdminAlbumBadge from './AdminAlbumBadge';
import { deleteAlbumAction } from '@/album/actions'; import { deleteAlbumFormAction } from '@/album/actions';
export default async function AdminAlbumsTable({ export default async function AdminAlbumsTable({
albums, albums,
@ -31,10 +30,8 @@ export default async function AdminAlbumsTable({
)}> )}>
<EditButton path={pathForAdminAlbumEdit(album)} /> <EditButton path={pathForAdminAlbumEdit(album)} />
<FormWithConfirm <FormWithConfirm
action={deleteAlbumAction} action={deleteAlbumFormAction}
confirmText={ confirmText={deleteAlbumConfirmationText(album, count, appText)}
// eslint-disable-next-line max-len
`Are you sure you want to remove "${album.title}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`}
> >
<input type="hidden" name="album" value={album.id} /> <input type="hidden" name="album" value={album.id} />
<DeleteFormButton clearLocalState /> <DeleteFormButton clearLocalState />

View File

@ -17,7 +17,7 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react';
import { BiCheckCircle } from 'react-icons/bi'; import { BiCheckCircle } from 'react-icons/bi';
import ProgressButton from '@/components/primitives/ProgressButton'; import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient'; import { UrlAddStatus } from './AdminUploadsClient';
import PhotoTagFieldset from './PhotoTagFieldset'; import FieldsetTag from '../tag/FieldsetTag';
import DeleteUploadButton from './DeleteUploadButton'; import DeleteUploadButton from './DeleteUploadButton';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { pluralize } from '@/utility/string'; import { pluralize } from '@/utility/string';
@ -25,12 +25,15 @@ import FieldsetFavs from '@/photo/form/FieldsetFavs';
import IconAddUpload from '@/components/icons/IconAddUpload'; import IconAddUpload from '@/components/icons/IconAddUpload';
import { PhotoFormData } from '@/photo/form'; import { PhotoFormData } from '@/photo/form';
import FieldsetVisibility from '@/photo/visibility/FieldsetVisibility'; import FieldsetVisibility from '@/photo/visibility/FieldsetVisibility';
import { Albums } from '@/album';
import FieldsetAlbum from '@/album/FieldsetAlbum';
const UPLOAD_BATCH_SIZE = 2; const UPLOAD_BATCH_SIZE = 2;
export default function AdminBatchUploadActions({ export default function AdminBatchUploadActions({
uploadUrls, uploadUrls,
uploadTitles, uploadTitles,
uniqueAlbums,
uniqueTags, uniqueTags,
isAdding, isAdding,
setIsAdding, setIsAdding,
@ -41,6 +44,7 @@ export default function AdminBatchUploadActions({
}: { }: {
uploadUrls: string[] uploadUrls: string[]
uploadTitles: string[] uploadTitles: string[]
uniqueAlbums: Albums
uniqueTags?: Tags uniqueTags?: Tags
isAdding: boolean isAdding: boolean
setIsAdding: Dispatch<SetStateAction<boolean>> setIsAdding: Dispatch<SetStateAction<boolean>>
@ -54,6 +58,7 @@ export default function AdminBatchUploadActions({
const [showBulkSettings, setShowBulkSettings] = useState(false); const [showBulkSettings, setShowBulkSettings] = useState(false);
const [tagErrorMessage, setTagErrorMessage] = useState(''); const [tagErrorMessage, setTagErrorMessage] = useState('');
const [formData, setFormData] = useState<Partial<PhotoFormData>>({}); const [formData, setFormData] = useState<Partial<PhotoFormData>>({});
const [albumTitles, setAlbumTitles] = useState<string>();
const [buttonText, setButtonText] = useState('Add All Uploads'); const [buttonText, setButtonText] = useState('Add All Uploads');
const [actionErrorMessage, setActionErrorMessage] = useState(''); const [actionErrorMessage, setActionErrorMessage] = useState('');
@ -74,6 +79,7 @@ export default function AdminBatchUploadActions({
uploadUrls: urls, uploadUrls: urls,
uploadTitles: titles, uploadTitles: titles,
...showBulkSettings && { ...showBulkSettings && {
albumTitles: albumTitles?.split(','),
tags, tags,
favorite, favorite,
excludeFromFeeds, excludeFromFeeds,
@ -128,7 +134,7 @@ export default function AdminBatchUploadActions({
<> <>
{actionErrorMessage && {actionErrorMessage &&
<ErrorNote>{actionErrorMessage}</ErrorNote>} <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="w-full space-y-4">
<div className="flex"> <div className="flex">
<div className="grow text-main"> <div className="grow text-main">
@ -146,7 +152,14 @@ export default function AdminBatchUploadActions({
</div> </div>
{showBulkSettings && !actionErrorMessage && {showBulkSettings && !actionErrorMessage &&
<div className="space-y-4 mb-6"> <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" label="Tags"
tags={formData.tags ?? ''} tags={formData.tags ?? ''}
tagOptions={uniqueTags} tagOptions={uniqueTags}

View File

@ -20,7 +20,6 @@ import {
} from '@/photo'; } from '@/photo';
import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag'; import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu, { MoreMenuSection } from '@/components/more/MoreMenu'; import MoreMenu, { MoreMenuSection } from '@/components/more/MoreMenu';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
@ -34,6 +33,7 @@ import { photoNeedsToBeUpdated } from '@/photo/update';
import { KEY_COMMANDS } from '@/photo/key-commands'; import { KEY_COMMANDS } from '@/photo/key-commands';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import IconLock from '@/components/icons/IconLock'; import IconLock from '@/components/icons/IconLock';
import IconTrash from '@/components/icons/IconTrash';
export default function AdminPhotoMenu({ export default function AdminPhotoMenu({
photo, photo,
@ -63,7 +63,6 @@ export default function AdminPhotoMenu({
: PATH_ROOT : PATH_ROOT
: undefined; : undefined;
const sectionMain = useMemo(() => { const sectionMain = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{ const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: appText.admin.edit, label: appText.admin.edit,
@ -154,8 +153,7 @@ export default function AdminPhotoMenu({
const sectionDelete: MoreMenuSection = useMemo(() => ({ const sectionDelete: MoreMenuSection = useMemo(() => ({
items: [{ items: [{
label: appText.admin.delete, label: appText.admin.delete,
icon: <BiTrash icon: <IconTrash
size={15}
className="translate-x-[-1px]" className="translate-x-[-1px]"
/>, />,
className: 'text-error *:hover:text-error', className: 'text-error *:hover:text-error',

View File

@ -1,10 +1,9 @@
import FormWithConfirm from '@/components/FormWithConfirm'; import FormWithConfirm from '@/components/FormWithConfirm';
import { deletePhotoTagGloballyAction } from '@/photo/actions'; import { deletePhotoTagGloballyFormAction } from '@/photo/actions';
import AdminTable from '@/admin/AdminTable'; import AdminTable from '@/admin/AdminTable';
import { Fragment } from 'react'; import { Fragment } from 'react';
import DeleteFormButton from '@/admin/DeleteFormButton'; import DeleteFormButton from '@/admin/DeleteFormButton';
import { photoQuantityText } from '@/photo'; import { Tags, deleteTagConfirmationText, sortTags } from '@/tag';
import { Tags, formatTag, sortTags } from '@/tag';
import EditButton from '@/admin/EditButton'; import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/app/path'; import { pathForAdminTagEdit } from '@/app/path';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
@ -31,10 +30,8 @@ export default async function AdminTagsTable({
)}> )}>
<EditButton path={pathForAdminTagEdit(tag)} /> <EditButton path={pathForAdminTagEdit(tag)} />
<FormWithConfirm <FormWithConfirm
action={deletePhotoTagGloballyAction} action={deletePhotoTagGloballyFormAction}
confirmText={ confirmText={deleteTagConfirmationText(tag, count, appText)}
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`}
> >
<input type="hidden" name="tag" value={tag} /> <input type="hidden" name="tag" value={tag} />
<DeleteFormButton clearLocalState /> <DeleteFormButton clearLocalState />

View File

@ -5,6 +5,7 @@ import AdminBatchUploadActions from './AdminBatchUploadActions';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Tags } from '@/tag'; import { Tags } from '@/tag';
import AdminUploadsTable from './AdminUploadsTable'; import AdminUploadsTable from './AdminUploadsTable';
import { Albums } from '@/album';
export type UrlAddStatus = StorageListItem & { export type UrlAddStatus = StorageListItem & {
status?: 'waiting' | 'adding' | 'added' status?: 'waiting' | 'adding' | 'added'
@ -16,9 +17,11 @@ export type UrlAddStatus = StorageListItem & {
export default function AdminUploadsClient({ export default function AdminUploadsClient({
urls, urls,
uniqueTags, uniqueTags,
uniqueAlbums,
}: { }: {
urls: StorageListResponse urls: StorageListResponse
uniqueTags?: Tags uniqueTags: Tags
uniqueAlbums: Albums
}) { }) {
const [urlAddStatuses, setUrlAddStatuses] = useState<UrlAddStatus[]>(urls); const [urlAddStatuses, setUrlAddStatuses] = useState<UrlAddStatus[]>(urls);
@ -41,6 +44,7 @@ export default function AdminUploadsClient({
<AdminBatchUploadActions {...{ <AdminBatchUploadActions {...{
uploadUrls, uploadUrls,
uploadTitles, uploadTitles,
uniqueAlbums,
uniqueTags, uniqueTags,
isAdding, isAdding,
setIsAdding, setIsAdding,

View File

@ -1,13 +1,19 @@
import { getUniqueTagsCached } from '@/photo/cache'; import { getUniqueTagsCached } from '@/photo/cache';
import AdminBatchEditPanelClient from './AdminBatchEditPanelClient'; import AdminBatchEditPanelClient from './AdminBatchEditPanelClient';
import { getAlbumsWithMeta } from '@/album/query';
export default async function AdminBatchEditPanel({ export default async function AdminBatchEditPanel({
onBatchActionComplete, onBatchActionComplete,
}: { }: {
onBatchActionComplete?: () => Promise<void> onBatchActionComplete?: () => Promise<void>
}) { }) {
const uniqueAlbums = await getAlbumsWithMeta().catch(() => []);
const uniqueTags = await getUniqueTagsCached().catch(() => []); const uniqueTags = await getUniqueTagsCached().catch(() => []);
return ( 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 { IoCloseSharp } from 'react-icons/io5';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { TAG_FAVS, Tags } from '@/tag'; import { TAG_FAVS, Tags } from '@/tag';
import PhotoTagFieldset from '@/admin/PhotoTagFieldset'; import FieldsetTag from '@/tag/FieldsetTag';
import { tagMultiplePhotosAction } from '@/photo/actions'; import { tagMultiplePhotosAction } from '@/photo/actions';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import DeletePhotosButton from '@/admin/DeletePhotosButton'; import DeletePhotosButton from '@/admin/DeletePhotosButton';
@ -18,10 +18,16 @@ import IconFavs from '@/components/icons/IconFavs';
import IconTag from '@/components/icons/IconTag'; import IconTag from '@/components/icons/IconTag';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import { useSelectPhotosState } from './SelectPhotosState'; 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({ export default function AdminBatchEditPanelClient({
uniqueAlbums,
uniqueTags, uniqueTags,
}: { }: {
uniqueAlbums: Albums
uniqueTags: Tags uniqueTags: Tags
}) { }) {
const refNote = useRef<HTMLDivElement>(null); const refNote = useRef<HTMLDivElement>(null);
@ -37,6 +43,9 @@ export default function AdminBatchEditPanelClient({
const appText = useAppText(); const appText = useAppText();
const [albumTitles, setAlbumsTitles] = useState<string>();
const isInAlbumMode = albumTitles !== undefined;
const [tags, setTags] = useState<string>(); const [tags, setTags] = useState<string>();
const [tagErrorMessage, setTagErrorMessage] = useState(''); const [tagErrorMessage, setTagErrorMessage] = useState('');
const isInTagMode = tags !== undefined; const isInTagMode = tags !== undefined;
@ -55,7 +64,7 @@ export default function AdminBatchEditPanelClient({
const renderPhotoCTA = selectedPhotoIds?.length === 0 const renderPhotoCTA = selectedPhotoIds?.length === 0
? <> ? <>
<FaArrowDown /> <FaArrowDown />
<ResponsiveText shortText="Select below"> <ResponsiveText shortText="Select">
Select photos below Select photos below
</ResponsiveText> </ResponsiveText>
</> </>
@ -63,7 +72,7 @@ export default function AdminBatchEditPanelClient({
{photosText} selected {photosText} selected
</ResponsiveText>; </ResponsiveText>;
const renderActions = isInTagMode const renderActions = isInTagMode || isInAlbumMode
? <> ? <>
<LoaderButton <LoaderButton
className="min-h-[2.5rem]" className="min-h-[2.5rem]"
@ -72,20 +81,23 @@ export default function AdminBatchEditPanelClient({
className="translate-y-[0.5px]" className="translate-y-[0.5px]"
/>} />}
onClick={() => { onClick={() => {
setAlbumsTitles(undefined);
setTags(undefined); setTags(undefined);
setTagErrorMessage(''); setTagErrorMessage('');
}} }}
disabled={isPerformingSelectEdit} disabled={isPerformingSelectEdit}
> />
Cancel
</LoaderButton>
<LoaderButton <LoaderButton
className="min-h-[2.5rem]" className="min-h-[2.5rem]"
icon={<FaCheck size={15} />} icon={<FaCheck size={15} />}
confirmText={isInTagMode
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
confirmText={`Are you sure you want to apply tags to ${photosText}? This action cannot be undone.`} ? `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={() => { onClick={() => {
setIsPerformingSelectEdit?.(true); setIsPerformingSelectEdit?.(true);
if (isInTagMode) {
tagMultiplePhotosAction( tagMultiplePhotosAction(
tags, tags,
selectedPhotoIds ?? [], selectedPhotoIds ?? [],
@ -95,16 +107,29 @@ export default function AdminBatchEditPanelClient({
stopSelectingPhotos?.(); stopSelectingPhotos?.();
}) })
.finally(() => setIsPerformingSelectEdit?.(false)); .finally(() => setIsPerformingSelectEdit?.(false));
} else if (isInAlbumMode) {
addPhotosToAlbumsAction(
selectedPhotoIds ?? [],
albumTitles.split(','),
)
.then(() => {
toastSuccess(`${photosText} added`);
stopSelectingPhotos?.();
})
.finally(() => setIsPerformingSelectEdit?.(false));
}
}} }}
disabled={ disabled={
!tags || (
Boolean(tagErrorMessage) || (!tags || Boolean(tagErrorMessage)) &&
!albumTitles
) ||
(selectedPhotoIds?.length ?? 0) === 0 || (selectedPhotoIds?.length ?? 0) === 0 ||
isPerformingSelectEdit isPerformingSelectEdit
} }
primary primary
> >
Apply Tags Apply
</LoaderButton> </LoaderButton>
</> </>
: <> : <>
@ -132,12 +157,19 @@ export default function AdminBatchEditPanelClient({
.finally(() => setIsPerformingSelectEdit?.(false)); .finally(() => setIsPerformingSelectEdit?.(false));
}} }}
/> />
<LoaderButton
onClick={() => setAlbumsTitles('')}
disabled={isFormDisabled}
icon={<IconAlbum size={15} className="translate-y-[1.5px]" />}
>
Album
</LoaderButton>
<LoaderButton <LoaderButton
onClick={() => setTags('')} onClick={() => setTags('')}
disabled={isFormDisabled} disabled={isFormDisabled}
icon={<IconTag size={15} className="translate-y-[1.5px]" />} icon={<IconTag size={15} className="translate-y-[1.5px]" />}
> >
Tag ... Tag
</LoaderButton> </LoaderButton>
<LoaderButton <LoaderButton
icon={<IoCloseSharp size={19} />} icon={<IoCloseSharp size={19} />}
@ -178,8 +210,17 @@ export default function AdminBatchEditPanelClient({
spaceChildren={false} spaceChildren={false}
hideIcon hideIcon
> >
{isInTagMode {isInAlbumMode
? <PhotoTagFieldset ? <FieldsetAlbum
albumOptions={uniqueAlbums}
value={albumTitles}
onChange={setAlbumsTitles}
readOnly={isPerformingSelectEdit}
openOnLoad
hideLabel
/>
: isInTagMode
? <FieldsetTag
tags={tags} tags={tags}
tagOptions={uniqueTags} tagOptions={uniqueTags}
placeholder={`Tag ${photosText} ...`} placeholder={`Tag ${photosText} ...`}

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, SHOW_CATEGORY_IMAGE_HOVERS,
} from '@/app/config'; } from '@/app/config';
import { getAppText } from '@/i18n/state/server'; import { getAppText } from '@/i18n/state/server';
import { Album, descriptionForAlbumPhotos } from '.'; import { Album, albumHasMeta, descriptionForAlbumPhotos } from '.';
import { safelyParseFormattedHtml } from '@/utility/html'; import { safelyParseFormattedHtml } from '@/utility/html';
import PhotoAlbum from './PhotoAlbum'; import PhotoAlbum from './PhotoAlbum';
import PhotoTag from '@/tag/PhotoTag'; import PhotoTag from '@/tag/PhotoTag';
@ -39,6 +39,7 @@ export default async function AlbumHeader({
album={album} album={album}
contrast="high" contrast="high"
hoverType="none" hoverType="none"
showAdminMenu
/>} />}
entityDescription={descriptionForAlbumPhotos( entityDescription={descriptionForAlbumPhotos(
photos, photos,
@ -51,7 +52,7 @@ export default async function AlbumHeader({
indexNumber={indexNumber} indexNumber={indexNumber}
count={count} count={count}
dateRange={dateRange} dateRange={dateRange}
richContent={showAlbumMeta richContent={showAlbumMeta && (albumHasMeta(album) || tags.length > 0)
? <div className="space-y-2"> ? <div className="space-y-2">
{album.subhead && {album.subhead &&
<div className="text-medium mb-6 uppercase font-medium"> <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 FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { Albums } from '.'; import { Albums } from '.';
import { convertAlbumsToAnnotatedTags } from './form'; import { convertAlbumsToAnnotatedTags } from './form';
export default function FieldsetAlbum({ export default function FieldsetAlbum({
albumOptions, albumOptions,
label,
openOnLoad,
...props ...props
}: { }: {
albumOptions: Albums 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 ( return (
<div ref={ref}>
<FieldsetWithStatus <FieldsetWithStatus
{...props} {...props}
label={label ?? 'Albums'}
tagOptions={convertAlbumsToAnnotatedTags(albumOptions)} tagOptions={convertAlbumsToAnnotatedTags(albumOptions)}
tagOptionsShouldParameterize={false} tagOptionsShouldParameterize={false}
/> />
</div>
); );
} }

View File

@ -6,14 +6,20 @@ import EntityLink, { EntityLinkExternalProps } from
import IconAlbum from '@/components/icons/IconAlbum'; import IconAlbum from '@/components/icons/IconAlbum';
import { Album } from '.'; import { Album } from '.';
import useCategoryCounts from '@/category/useCategoryCounts'; import useCategoryCounts from '@/category/useCategoryCounts';
import AdminAlbumMenu from './AdminAlbumMenu';
import { useAppState } from '@/app/AppState';
export default function PhotoAlbum({ export default function PhotoAlbum({
album, album,
showAdminMenu,
...props ...props
}: { }: {
album: Album album: Album
showAdminMenu?: boolean
} & EntityLinkExternalProps) { } & EntityLinkExternalProps) {
const { getAlbumCount } = useCategoryCounts(); const { getAlbumCount } = useCategoryCounts();
const { isUserSignedIn } = useAppState();
const count = props.hoverCount ?? getAlbumCount(album);
return ( return (
<EntityLink <EntityLink
{...props} {...props}
@ -22,6 +28,8 @@ export default function PhotoAlbum({
hoverQueryOptions={{ album }} hoverQueryOptions={{ album }}
icon={<IconAlbum className="translate-y-[-0.5px]" />} icon={<IconAlbum className="translate-y-[-0.5px]" />}
hoverCount={props.hoverCount ?? getAlbumCount(album)} hoverCount={props.hoverCount ?? getAlbumCount(album)}
action={showAdminMenu && isUserSignedIn &&
<AdminAlbumMenu {...{ album, count }} />}
/> />
); );
} }

View File

@ -1,11 +1,13 @@
'use server'; 'use server';
import { runAuthenticatedAdminServerAction } from '@/auth/server'; import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { deleteAlbum, updateAlbum } from './query'; import { addPhotoAlbumIds, deleteAlbum, updateAlbum } from './query';
import { revalidateAllKeysAndPaths } from '@/photo/cache'; import { revalidateAllKeysAndPaths } from '@/photo/cache';
import { redirect } from 'next/navigation'; 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 { convertFormDataToAlbum } from './form';
import { Album } from '.';
import { createAlbumsAndGetIds } from './server';
export const updateAlbumAction = async (formData: FormData) => export const updateAlbumAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
@ -15,9 +17,31 @@ export const updateAlbumAction = async (formData: FormData) =>
redirect(PATH_ADMIN_ALBUMS); redirect(PATH_ADMIN_ALBUMS);
}); });
export const deleteAlbumAction = async (formData: FormData) => export const deleteAlbumFormAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const albumId = formData.get('album') as string; const albumId = formData.get('album') as string;
await deleteAlbum(albumId); await deleteAlbum(albumId);
revalidateAllKeysAndPaths(); 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 => export const parseAlbumFromDb = (album: any): Album =>
camelcaseKeys(album); camelcaseKeys(album);
export const albumHasMeta = (album: Album) =>
album.subhead ||
album.description ||
album.locationName ||
album.latitude ||
album.longitude;
export const titleForAlbum = ( export const titleForAlbum = (
album: Album, album: Album,
photos:Photo[] = [], photos:Photo[] = [],
@ -83,3 +90,12 @@ export const generateMetaForAlbum = (
), ),
images: absolutePathForAlbumImage(album), 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 { safelyQuery } from '@/db/query';
import { sql } from '@/platforms/postgres'; import { query, sql } from '@/platforms/postgres';
import { generateManyToManyValues } from '@/db';
import { Album, Albums, parseAlbumFromDb } from '.'; import { Album, Albums, parseAlbumFromDb } from '.';
export const createAlbumsTable = () => export const createAlbumsTable = () =>
@ -100,11 +101,22 @@ export const clearPhotoAlbumIds = (photoId: string) =>
DELETE FROM album_photo WHERE photo_id=${photoId} DELETE FROM album_photo WHERE photo_id=${photoId}
`, 'clearPhotoAlbumIds'); `, 'clearPhotoAlbumIds');
export const addPhotoAlbumId = (photoId: string, albumId: string) => export const addPhotoAlbumIds = (photoIds: string[], albumIds: string[]) => {
safelyQuery(() => sql` if (photoIds.length > 0 && albumIds.length > 0) {
INSERT INTO album_photo (album_id, photo_id) VALUES (${albumId}, ${photoId}) 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 ON CONFLICT (album_id, photo_id) DO NOTHING
`, 'updateAlbumPhoto'); `, values), 'updateAlbumPhoto');
}
};
export const addPhotoAlbumId = (photoId: string, albumId: string) =>
addPhotoAlbumIds([photoId], [albumId]);
export const getAlbumTitlesForPhoto = (photoId: string) => export const getAlbumTitlesForPhoto = (photoId: string) =>
safelyQuery(() => sql<{ title: string }>` safelyQuery(() => sql<{ title: string }>`

View File

@ -1,12 +1,13 @@
import { parameterize } from '@/utility/string'; import { capitalize, capitalizeWords, parameterize } from '@/utility/string';
import { import {
addPhotoAlbumId, addPhotoAlbumId,
clearPhotoAlbumIds, clearPhotoAlbumIds,
getAlbumsWithMeta, getAlbumsWithMeta,
insertAlbum, insertAlbum,
} from './query'; } from './query';
import { deletePhotoTagGlobally, getPhotos } from '@/photo/query';
const createAlbumsAndGetIds = async (titles: string[]) => { export const createAlbumsAndGetIds = async (titles: string[]) => {
const albums = await getAlbumsWithMeta(); const albums = await getAlbumsWithMeta();
return Promise.all(titles.map(async title => { return Promise.all(titles.map(async title => {
const album = albums.find(({ album }) => album.title === title); const album = albums.find(({ album }) => album.title === title);
@ -28,3 +29,21 @@ export const addAlbumTitlesToPhoto = async (
if (shouldClearPhotoAlbumIds) { await clearPhotoAlbumIds(photoId); } if (shouldClearPhotoAlbumIds) { await clearPhotoAlbumIds(photoId); }
await Promise.all(albumIds.map(albumId => addPhotoAlbumId(photoId, albumId))); 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 { parameterize } from '@/utility/string';
import { TAG_PRIVATE } from '@/tag'; import { TAG_PRIVATE } from '@/tag';
import { Lens } from '@/lens'; import { Lens } from '@/lens';
import { Album, AlbumOrAlbumSlug } from '@/album'; import { AlbumOrAlbumSlug } from '@/album';
// Core // Core
export const PATH_ROOT = '/'; export const PATH_ROOT = '/';
@ -130,6 +130,16 @@ type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory & {
showRecipe?: boolean 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) => export const pathForAdminUploadUrl = (url: string, title?: string) =>
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
`${PATH_ADMIN_UPLOADS}/${encodeURIComponent(url)}${title ? `?${PARAM_UPLOAD_TITLE}=${encodeURIComponent(title)}` : ''}`; `${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) => export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) =>
`${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/${EDIT}`; `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/${EDIT}`;
export const pathForAdminAlbumEdit = (album: Album) => export const pathForAdminAlbumEdit = (album: AlbumOrAlbumSlug) =>
`${PATH_ADMIN_ALBUMS}/${album.slug}/${EDIT}`; `${PATH_ADMIN_ALBUMS}/${getAlbumSlug(album)}/${EDIT}`;
export const pathForAdminTagEdit = (tag: string) => export const pathForAdminTagEdit = (tag: string) =>
`${PATH_ADMIN_TAGS}/${tag}/${EDIT}`; `${PATH_ADMIN_TAGS}/${tag}/${EDIT}`;
@ -148,9 +158,6 @@ export const pathForAdminRecipeEdit = (recipe: string) =>
type PhotoOrPhotoId = Photo | string; type PhotoOrPhotoId = Photo | string;
const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
typeof photoOrPhotoId === 'string' ? photoOrPhotoId : photoOrPhotoId.id;
export const pathForPhoto = ({ export const pathForPhoto = ({
photo, photo,
recent, recent,
@ -202,7 +209,7 @@ export const pathForLens = ({ make, model }: Lens) =>
: `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`; : `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`;
export const pathForAlbum = (album: AlbumOrAlbumSlug) => export const pathForAlbum = (album: AlbumOrAlbumSlug) =>
`${PREFIX_ALBUM}/${typeof album === 'string' ? album : album.slug}`; `${PREFIX_ALBUM}/${getAlbumSlug(album)}`;
export const pathForTag = (tag: string) => export const pathForTag = (tag: string) =>
`${PREFIX_TAG}/${tag}`; `${PREFIX_TAG}/${tag}`;
@ -229,7 +236,7 @@ export const pathForCameraImage = (camera: Camera) =>
export const pathForLensImage = (lens: Lens) => export const pathForLensImage = (lens: Lens) =>
pathForImage(pathForLens(lens)); pathForImage(pathForLens(lens));
export const pathForAlbumImage = (album: Album) => export const pathForAlbumImage = (album: AlbumOrAlbumSlug) =>
pathForImage(pathForAlbum(album)); pathForImage(pathForAlbum(album));
export const pathForTagImage = (tag: string) => export const pathForTagImage = (tag: string) =>
@ -278,7 +285,10 @@ export const absolutePathForCamera= (camera: Camera, share?: boolean) =>
export const absolutePathForLens= (lens: Lens, share?: boolean) => export const absolutePathForLens= (lens: Lens, share?: boolean) =>
`${getBaseUrl(share)}${pathForLens(lens)}`; `${getBaseUrl(share)}${pathForLens(lens)}`;
export const absolutePathForAlbum = (album: Album, share?: boolean) => export const absolutePathForAlbum = (
album: AlbumOrAlbumSlug,
share?: boolean,
) =>
`${getBaseUrl(share)}${pathForAlbum(album)}`; `${getBaseUrl(share)}${pathForAlbum(album)}`;
export const absolutePathForTag = (tag: string, share?: boolean) => export const absolutePathForTag = (tag: string, share?: boolean) =>
@ -308,7 +318,7 @@ export const absolutePathForCameraImage= (camera: Camera) =>
export const absolutePathForLensImage= (lens: Lens) => export const absolutePathForLensImage= (lens: Lens) =>
`${absolutePathForLens(lens)}/${IMAGE}`; `${absolutePathForLens(lens)}/${IMAGE}`;
export const absolutePathForAlbumImage = (album: Album) => export const absolutePathForAlbumImage = (album: AlbumOrAlbumSlug) =>
`${absolutePathForAlbum(album)}/${IMAGE}`; `${absolutePathForAlbum(album)}/${IMAGE}`;
export const absolutePathForTagImage = (tag: string) => export const absolutePathForTagImage = (tag: string) =>

View File

@ -36,7 +36,7 @@ export default function FieldsetWithStatus({
placeholder, placeholder,
loading, loading,
required, required,
readOnly, readOnly: readOnlyProp,
spellCheck, spellCheck,
capitalize, capitalize,
type = 'text', type = 'text',
@ -86,6 +86,8 @@ export default function FieldsetWithStatus({
const { pending } = useFormStatus(); const { pending } = useFormStatus();
const readOnly = readOnlyProp || pending || loading;
const inputProps: InputHTMLAttributes<HTMLInputElement> = { const inputProps: InputHTMLAttributes<HTMLInputElement> = {
id, id,
name: id, name: id,
@ -101,7 +103,7 @@ export default function FieldsetWithStatus({
spellCheck, spellCheck,
autoComplete: 'off', autoComplete: 'off',
autoCapitalize: !capitalize ? 'off' : undefined, autoCapitalize: !capitalize ? 'off' : undefined,
readOnly: readOnly || pending || loading, readOnly,
disabled: type === 'checkbox' && ( disabled: type === 'checkbox' && (
readOnly || pending || loading readOnly || pending || loading
), ),
@ -167,7 +169,7 @@ export default function FieldsetWithStatus({
{isModified && !error && {isModified && !error &&
<span className={clsx( <span className={clsx(
'text-main font-medium text-[0.9rem]', 'text-main font-medium text-[0.9rem]',
' -ml-1.5 translate-y-[-1px]', ' -ml-1.5 translate-y-[-1px] -z-1',
)}> )}>
* *
</span>} </span>}
@ -196,7 +198,7 @@ export default function FieldsetWithStatus({
options={selectOptions} options={selectOptions}
defaultOptionLabel={selectOptionsDefaultLabel} defaultOptionLabel={selectOptionsDefaultLabel}
error={error} error={error}
readOnly={readOnly || pending || loading} readOnly={readOnly}
/> />
: tagOptions : tagOptions
? <TagInput ? <TagInput
@ -208,7 +210,7 @@ export default function FieldsetWithStatus({
onChange={onChange} onChange={onChange}
showMenuOnDelete={tagOptionsLimit === 1} showMenuOnDelete={tagOptionsLimit === 1}
className={clsx(Boolean(error) && 'error')} className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending || loading} readOnly={readOnly}
placeholder={placeholder} placeholder={placeholder}
limit={tagOptionsLimit} limit={tagOptionsLimit}
limitValidationMessage={tagOptionsLimitValidationMessage} limitValidationMessage={tagOptionsLimitValidationMessage}
@ -221,7 +223,7 @@ export default function FieldsetWithStatus({
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
onChange={e => onChange?.(e.target.value)} onChange={e => onChange?.(e.target.value)}
readOnly={readOnly || pending || loading} readOnly={readOnly}
spellCheck={spellCheck} spellCheck={spellCheck}
autoCapitalize={!capitalize ? 'off' : undefined} autoCapitalize={!capitalize ? 'off' : undefined}
className={clsx( className={clsx(
@ -235,6 +237,7 @@ export default function FieldsetWithStatus({
accessory={loading && <Spinner accessory={loading && <Spinner
className="translate-y-[0.5px]" className="translate-y-[0.5px]"
/>} />}
readOnly={readOnly}
{...inputProps} {...inputProps}
/> />
: <input : <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', 'text-dim',
'outline-none', 'outline-none',
classNameButton, classNameButton,
isOpen && 'bg-dim',
isOpen && classNameButtonOpen, isOpen && classNameButtonOpen,
)} )}
aria-label={ariaLabel} aria-label={ariaLabel}
@ -94,8 +95,10 @@ export default function MoreMenu({
'not-dark:shadow-lg not-dark:shadow-gray-900/10', '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=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=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=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top', 'data-[side=bottom]:animate-fade-in-from-top',
'data-[side=right]:animate-fade-in-from-top',
className, className,
)} )}
> >

View File

@ -218,3 +218,33 @@ export const getLimitAndOffsetFromOptions = (
limitAndOffsetValues: [limit, offset], 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_ADMIN_TAGS,
PATH_ROOT, PATH_ROOT,
pathForPhoto, pathForPhoto,
pathForTag,
} from '@/app/path'; } from '@/app/path';
import { import {
blurImageFromUrl, blurImageFromUrl,
@ -68,7 +69,12 @@ import {
} from '@/photo/color/server'; } from '@/photo/color/server';
import { shouldBackfillPhotoStorage } from './update/server'; import { shouldBackfillPhotoStorage } from './update/server';
import { getAlbumTitlesFromFormData } from '@/album/form'; import { getAlbumTitlesFromFormData } from '@/album/form';
import { addAlbumTitlesToPhoto } from '@/album/server'; import {
addAlbumTitlesToPhoto,
createAlbumsAndGetIds,
upgradeTagToAlbum,
} from '@/album/server';
import { addPhotoAlbumIds } from '@/album/query';
// Private actions // Private actions
@ -102,6 +108,7 @@ export const createPhotoAction = async (formData: FormData) =>
const addUpload = async ({ const addUpload = async ({
url, url,
title: _title, title: _title,
albumIds = [],
tags: _tags, tags: _tags,
favorite, favorite,
hidden, hidden,
@ -114,6 +121,7 @@ const addUpload = async ({
}:{ }:{
url: string url: string
title?: string title?: string
albumIds?: string[]
tags?: string tags?: string
favorite?: string favorite?: string
hidden?: string hidden?: string
@ -190,6 +198,9 @@ const addUpload = async ({
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(form); await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(form);
photo.url = updatedUrl; photo.url = updatedUrl;
await insertPhoto(photo); await insertPhoto(photo);
if (albumIds.length > 0) {
await addPhotoAlbumIds([photo.id], albumIds);
}
if (shouldRevalidateAllKeysAndPaths) { if (shouldRevalidateAllKeysAndPaths) {
after(revalidateAllKeysAndPaths); after(revalidateAllKeysAndPaths);
} }
@ -207,6 +218,7 @@ export const addUploadsAction = async ({
uploadUrls, uploadUrls,
uploadTitles, uploadTitles,
shouldRevalidateAllKeysAndPaths = true, shouldRevalidateAllKeysAndPaths = true,
albumTitles,
tags, tags,
favorite, favorite,
hidden, hidden,
@ -215,11 +227,12 @@ export const addUploadsAction = async ({
takenAtNaiveLocal, takenAtNaiveLocal,
}: Omit< }: Omit<
Parameters<typeof addUpload>[0], Parameters<typeof addUpload>[0],
'url' | 'onStreamUpdate' | 'onFinish' 'url' | 'onStreamUpdate' | 'onFinish' | 'albumIds'
> & { > & {
uploadUrls: string[] uploadUrls: string[]
uploadTitles: string[] uploadTitles: string[]
shouldRevalidateAllKeysAndPaths?: boolean shouldRevalidateAllKeysAndPaths?: boolean
albumTitles?: string[]
}) => }) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const PROGRESS_TASK_COUNT = AI_CONTENT_GENERATION_ENABLED ? 5 : 4; const PROGRESS_TASK_COUNT = AI_CONTENT_GENERATION_ENABLED ? 5 : 4;
@ -241,6 +254,10 @@ export const addUploadsAction = async ({
progress: ++progress / PROGRESS_TASK_COUNT, progress: ++progress / PROGRESS_TASK_COUNT,
}); });
const albumIds = albumTitles
? await createAlbumsAndGetIds(albumTitles)
: [];
(async () => { (async () => {
try { try {
for (const [index, url] of uploadUrls.entries()) { for (const [index, url] of uploadUrls.entries()) {
@ -252,6 +269,7 @@ export const addUploadsAction = async ({
await addUpload({ await addUpload({
url, url,
title, title,
albumIds,
tags, tags,
favorite, favorite,
hidden, hidden,
@ -380,16 +398,26 @@ export const deletePhotoAction = async (
} }
}); });
export const deletePhotoTagGloballyAction = async (formData: FormData) => export const deletePhotoTagGloballyFormAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const tag = formData.get('tag') as string; const tag = formData.get('tag') as string;
await deletePhotoTagGlobally(tag); await deletePhotoTagGlobally(tag);
revalidatePhotosKey(); revalidatePhotosKey();
revalidateAdminPaths(); 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) => export const renamePhotoTagGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const tag = formData.get('tag') as string; 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 ( export const getPhotosNeedingRecipeTitleCountAction = async (
recipeData: string, recipeData: string,
film: string, film: string,

View File

@ -597,7 +597,7 @@ export default function PhotoForm({
isModified={areAlbumTitlesModified} isModified={areAlbumTitlesModified}
className={clsx( className={clsx(
fieldProps.className, fieldProps.className,
'relative z-10', 'relative z-1',
)} )}
/>; />;
case 'visibility': case 'visibility':
@ -631,6 +631,7 @@ export default function PhotoForm({
<div className={clsx( <div className={clsx(
'flex gap-3 sticky bottom-0', 'flex gap-3 sticky bottom-0',
'pb-4 md:pb-8 mt-16', 'pb-4 md:pb-8 mt-16',
'relative z-10',
)}> )}>
<Link <Link
className="button" className="button"

View File

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

View File

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

View File

@ -47,17 +47,6 @@ export const sql = <T extends QueryResultRow>(
return query<T>(result, values); 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 = ( const isTemplateStringsArray = (
strings: unknown, strings: unknown,
): strings is TemplateStringsArray => { ): 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 { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
import { ComponentProps, useEffect, useRef, useState } from 'react'; import { ComponentProps, useEffect, useRef, useState } from 'react';
export default function PhotoTagFieldset(props: { export default function FieldsetTag(props: {
tags: string tags: string
tagOptions?: Tags tagOptions?: Tags
onChange: (tags: string) => void onChange: (tags: string) => void
@ -24,7 +24,7 @@ export default function PhotoTagFieldset(props: {
...rest ...rest
} = props; } = props;
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLDivElement>(null);
const appText = useAppText(); const appText = useAppText();
@ -43,7 +43,6 @@ export default function PhotoTagFieldset(props: {
<div ref={ref}> <div ref={ref}>
<FieldsetWithStatus <FieldsetWithStatus
{...rest} {...rest}
inputRef={ref}
label="Tags" label="Tags"
value={tags} value={tags}
tagOptions={convertTagsForForm(tagOptions, appText)} tagOptions={convertTagsForForm(tagOptions, appText)}

View File

@ -7,14 +7,20 @@ import EntityLink, {
} from '@/components/entity/EntityLink'; } from '@/components/entity/EntityLink';
import IconTag from '@/components/icons/IconTag'; import IconTag from '@/components/icons/IconTag';
import useCategoryCounts from '@/category/useCategoryCounts'; import useCategoryCounts from '@/category/useCategoryCounts';
import { useAppState } from '@/app/AppState';
import AdminTagMenu from './AdminTagMenu';
export default function PhotoTag({ export default function PhotoTag({
tag, tag,
showAdminMenu,
...props ...props
}: { }: {
tag: string tag: string
showAdminMenu?: boolean
} & EntityLinkExternalProps) { } & EntityLinkExternalProps) {
const { getTagCount } = useCategoryCounts(); const { getTagCount } = useCategoryCounts();
const { isUserSignedIn } = useAppState();
const count = props.hoverCount ?? getTagCount(tag);
return ( return (
<EntityLink <EntityLink
{...props} {...props}
@ -22,7 +28,9 @@ export default function PhotoTag({
path={pathForTag(tag)} path={pathForTag(tag)}
hoverQueryOptions={{ tag }} hoverQueryOptions={{ tag }}
icon={<IconTag size={14} className="translate-x-[0.5px]" />} 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} tag={tag}
contrast="high" contrast="high"
hoverType="none" hoverType="none"
showAdminMenu
/>} />}
entityVerb={appText.category.tagged} entityVerb={appText.category.tagged}
entityDescription={descriptionForTaggedPhotos( entityDescription={descriptionForTaggedPhotos(

View File

@ -128,6 +128,14 @@ export const generateMetaForTag = (
images: absolutePathForTagImage(tag), 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 isTagFavs = (tag: string) => tag.toLocaleLowerCase() === TAG_FAVS;
export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs); export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);

View File

@ -266,6 +266,8 @@ html {
text-medium text-medium
bg-gray-100 dark:bg-gray-900 bg-gray-100 dark:bg-gray-900
pointer-events-none pointer-events-none
read-only:bg-gray-100
dark:read-only:bg-gray-900 dark:read-only:text-gray-400
} }
input[type=file] { input[type=file] {
@apply @apply