Album upgrades (#326)
* Add tag-to-album upgrade, introduce tag/album ••• menus * Refine entity ••• menus * Add album tagging to "Select ..." mode * Finalize batch select/upload add album * Refine final tag/album interactions * Refine upgradeTagToAlbum capitalization * Fix batch album upload, z-index issues * Refine readonly styles
This commit is contained in:
parent
1e66815a3d
commit
ee9f3f4dc2
17
__tests__/postgres.test.ts
Normal file
17
__tests__/postgres.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/* eslint-disable max-len */
|
||||||
|
import { generateManyToManyValues } from '@/db';
|
||||||
|
|
||||||
|
describe('Postgres', () => {
|
||||||
|
it('Create many to many values', () => {
|
||||||
|
expect(generateManyToManyValues(['1'], ['3']))
|
||||||
|
.toEqual({
|
||||||
|
valueString: 'VALUES ($1,$2)',
|
||||||
|
values: ['1', '3'],
|
||||||
|
});
|
||||||
|
expect(generateManyToManyValues(['1', '2'], ['3', '4', '5']))
|
||||||
|
.toEqual({
|
||||||
|
valueString: 'VALUES ($1,$2),($3,$4),($5,$6),($7,$8),($9,$10),($11,$12)',
|
||||||
|
values: ['1', '3', '1', '4', '1', '5', '2', '3', '2', '4', '2', '5'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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',
|
||||||
|
|||||||
@ -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,
|
||||||
}} />}
|
}} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
}} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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} ...`}
|
||||||
|
|||||||
52
src/album/AdminAlbumMenu.tsx
Normal file
52
src/album/AdminAlbumMenu.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import MoreMenu from '@/components/more/MoreMenu';
|
||||||
|
import { pathForAdminAlbumEdit } from '@/app/path';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import IconEdit from '@/components/icons/IconEdit';
|
||||||
|
import { Album, deleteAlbumConfirmationText } from '.';
|
||||||
|
import { deleteAlbumAction } from './actions';
|
||||||
|
import IconTrash from '@/components/icons/IconTrash';
|
||||||
|
|
||||||
|
export default function AdminAlbumMenu({
|
||||||
|
album,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
album: Album
|
||||||
|
count: number
|
||||||
|
}) {
|
||||||
|
const appText = useAppText();
|
||||||
|
const path = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MoreMenu
|
||||||
|
ariaLabel="Album menu"
|
||||||
|
className="m-2"
|
||||||
|
classNameButton="h-3.5 translate-y-1"
|
||||||
|
side="right"
|
||||||
|
sections={[{
|
||||||
|
items: [{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <IconEdit
|
||||||
|
size={15}
|
||||||
|
className="translate-y-[0.5px]"
|
||||||
|
/>,
|
||||||
|
href: pathForAdminAlbumEdit(album),
|
||||||
|
}],
|
||||||
|
}, {
|
||||||
|
items: [{
|
||||||
|
icon: <IconTrash
|
||||||
|
className="translate-x-[-1px]"
|
||||||
|
/>,
|
||||||
|
label: 'Delete',
|
||||||
|
className: 'text-error *:hover:text-error',
|
||||||
|
color: 'red',
|
||||||
|
action: () => {
|
||||||
|
if (confirm(deleteAlbumConfirmationText(album, count, appText))) {
|
||||||
|
return deleteAlbumAction(album, path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import {
|
|||||||
SHOW_CATEGORY_IMAGE_HOVERS,
|
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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@ -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.';
|
||||||
|
|||||||
@ -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 }>`
|
||||||
|
|||||||
@ -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`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
9
src/components/icons/IconTrash.tsx
Normal file
9
src/components/icons/IconTrash.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { BiTrash } from 'react-icons/bi';
|
||||||
|
import { IconBaseProps } from 'react-icons/lib';
|
||||||
|
|
||||||
|
export default function IconTrash(props: IconBaseProps) {
|
||||||
|
return <BiTrash
|
||||||
|
{...props}
|
||||||
|
size={props.size ?? 15}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
@ -73,6 +73,7 @@ export default function MoreMenu({
|
|||||||
'text-dim',
|
'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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
74
src/tag/AdminTagMenu.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import MoreMenu from '@/components/more/MoreMenu';
|
||||||
|
import { TbFolderUp } from 'react-icons/tb';
|
||||||
|
import { deleteTagConfirmationText, formatTag } from '.';
|
||||||
|
import {
|
||||||
|
deletePhotoTagGloballyAction,
|
||||||
|
upgradeTagToAlbumAction,
|
||||||
|
} from '@/photo/actions';
|
||||||
|
import { toastSuccess } from '@/toast';
|
||||||
|
import { pathForAdminAlbumEdit, pathForAdminTagEdit } from '@/app/path';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import IconEdit from '@/components/icons/IconEdit';
|
||||||
|
import IconTrash from '@/components/icons/IconTrash';
|
||||||
|
|
||||||
|
export default function AdminTagMenu({
|
||||||
|
tag,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
tag: string
|
||||||
|
count: number
|
||||||
|
}) {
|
||||||
|
const appText = useAppText();
|
||||||
|
const path = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MoreMenu
|
||||||
|
ariaLabel="Tag menu"
|
||||||
|
className="m-2"
|
||||||
|
classNameButton="h-3.5 translate-y-1"
|
||||||
|
side="right"
|
||||||
|
sections={[{
|
||||||
|
items: [{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <IconEdit
|
||||||
|
size={15}
|
||||||
|
className="translate-y-[0.5px]"
|
||||||
|
/>,
|
||||||
|
href: pathForAdminTagEdit(tag),
|
||||||
|
}, {
|
||||||
|
icon: <TbFolderUp
|
||||||
|
size={16}
|
||||||
|
className="translate-x-[-1px]"
|
||||||
|
/>,
|
||||||
|
label: 'Upgrade',
|
||||||
|
action: () => {
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
if (confirm(`Are you sure you want to upgrade "${formatTag(tag)}" to an album?`)) {
|
||||||
|
return upgradeTagToAlbumAction(tag)
|
||||||
|
.then(() => {
|
||||||
|
toastSuccess(`"${formatTag(tag)}" upgraded to album`);
|
||||||
|
router.push(pathForAdminAlbumEdit(tag));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}, {
|
||||||
|
items: [{
|
||||||
|
icon: <IconTrash
|
||||||
|
className="translate-x-[-1px]"
|
||||||
|
/>,
|
||||||
|
label: 'Delete',
|
||||||
|
className: 'text-error *:hover:text-error',
|
||||||
|
color: 'red',
|
||||||
|
action: () => {
|
||||||
|
if (confirm(deleteTagConfirmationText(tag, count, appText))) {
|
||||||
|
return deletePhotoTagGloballyAction(tag, path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { useAppText } from '@/i18n/state/client';
|
|||||||
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
|
import { 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)}
|
||||||
@ -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 }} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user