Merge pull request #199 from sambecker/admin-menu

Rich admin menu
This commit is contained in:
Sam Becker 2025-02-26 23:33:50 -06:00 committed by GitHub
commit 7df506abdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 993 additions and 618 deletions

View File

@ -6,6 +6,7 @@
"ARROWLEFT",
"ARROWRIGHT",
"Astia",
"authjs",
"camelcase",
"cloudflarestorage",
"cmdk",

View File

@ -1,13 +1,9 @@
import ClearCacheButton from '@/admin/ClearCacheButton';
import AdminAppConfiguration from '@/admin/AdminAppConfiguration';
import AdminInfoPage from '@/admin/AdminInfoPage';
export default function AdminAppConfigurationPage() {
return (
<AdminInfoPage
title="App Configuration"
accessory={<ClearCacheButton />}
>
<AdminInfoPage>
<AdminAppConfiguration />
</AdminInfoPage>
);

View File

@ -2,7 +2,7 @@ import AdminAppInsights from '@/admin/insights/AdminAppInsights';
import AdminInfoPage from '@/admin/AdminInfoPage';
export default async function AdminInsightsPage() {
return <AdminInfoPage title="App Insights">
return <AdminInfoPage>
<AdminAppInsights />
</AdminInfoPage>;
}

View File

@ -82,12 +82,13 @@ export default function RootLayout({
'3xl:mx-auto 3xl:w-[1280px]',
)}>
<Nav siteDomainOrTitle={SITE_DOMAIN_OR_TITLE} />
<AdminBatchEditPanel />
<ShareModals />
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',
'space-y-5',
)}>
<ShareModals />
<AdminBatchEditPanel />
{children}
</div>
<Footer />

View File

@ -1,8 +1,10 @@
import { auth } from '@/auth';
import SignInForm from '@/auth/SignInForm';
import { PATH_ADMIN } from '@/app/paths';
import { PATH_ADMIN, PATH_ROOT } from '@/app/paths';
import { clsx } from 'clsx/lite';
import { redirect } from 'next/navigation';
import LinkWithStatus from '@/components/LinkWithStatus';
import { IoArrowBack } from 'react-icons/io5';
export default async function SignInPage() {
const session = await auth();
@ -17,6 +19,16 @@ export default async function SignInPage() {
'flex items-center justify-center flex-col gap-8',
)}>
<SignInForm />
<LinkWithStatus
href={PATH_ROOT}
className={clsx(
'flex items-center gap-2.5',
'text-lg',
)}
>
<IoArrowBack className="translate-y-[1px]" />
Home
</LinkWithStatus>
</div>
);
}

View File

@ -9,7 +9,7 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@ai-sdk/openai": "^1.1.12",
"@ai-sdk/openai": "^1.1.14",
"@aws-sdk/client-s3": "3.750.0",
"@aws-sdk/s3-request-presigner": "3.750.0",
"@radix-ui/react-dialog": "^1.1.6",
@ -21,14 +21,14 @@
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^0.27.1",
"@vercel/speed-insights": "^1.2.0",
"ai": "^4.1.41",
"ai": "^4.1.46",
"camelcase-keys": "^9.1.3",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"exifr": "^7.1.3",
"framer-motion": "^12.4.4",
"nanoid": "^5.1.0",
"framer-motion": "^12.4.7",
"nanoid": "^5.1.2",
"next": "15.1.7",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.4",
@ -45,30 +45,30 @@
"viewerjs": "^1.11.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/eslintrc": "^3.3.0",
"@next/bundle-analyzer": "15.1.7",
"@next/eslint-plugin-next": "^15.1.7",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.0.7",
"@tailwindcss/postcss": "^4.0.8",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.4",
"@types/node": "^22.13.5",
"@types/pg": "^8.11.11",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/sanitize-html": "^2.13.0",
"clsx": "^2.1.1",
"cross-fetch": "^4.1.0",
"eslint": "9.20.1",
"eslint": "9.21.0",
"eslint-config-next": "15.1.7",
"eslint-plugin-react-hooks": "^5.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "8.5.2",
"tailwindcss": "4.0.7",
"postcss": "8.5.3",
"tailwindcss": "4.0.8",
"ts-node": "^10.9.2",
"typescript": "5.7.3"
},

484
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import { LuCog } from 'react-icons/lu';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
export default function AdminAppInfoIcon({
size = 'large',
className,
}: {
size?: 'small' | 'large'
className?: string
}) {
const { insightIndicatorStatus } = useAppState();
return (
<span className={clsx(
'inline-flex relative',
className,
)}>
<LuCog
size={size === 'large' ? 20 : 17}
className="inline-flex translate-y-[1px]"
aria-label="App Info"
/>
{insightIndicatorStatus &&
<InsightsIndicatorDot
size={size}
top={size === 'large' ? 1.5 : 1.5}
right={size === 'large' ? 0.5 : 1}
/>}
</span>
);
}

View File

@ -2,62 +2,131 @@
import MoreMenu from '@/components/more/MoreMenu';
import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
PATH_GRID_INFERRED,
} from '@/app/paths';
import { useAppState } from '@/state/AppState';
import { ImCheckboxUnchecked } from 'react-icons/im';
import { IoCloseSharp } from 'react-icons/io5';
import AdminAppInsightsIcon from './insights/AdminAppInsightsIcon';
import { LuCog } from 'react-icons/lu';
import { clsx } from 'clsx/lite';
import { TbPhoto } from 'react-icons/tb';
import { FiTag } from 'react-icons/fi';
import { BiLockAlt } from 'react-icons/bi';
import AdminAppInfoIcon from './AdminAppInfoIcon';
import { PiSignOutBold } from 'react-icons/pi';
import { signOutAction } from '@/auth/actions';
import { ComponentProps } from 'react';
import { FaRegFolderOpen } from 'react-icons/fa';
export default function AdminAppMenu() {
export default function AdminAppMenu({
className,
buttonClassName,
}: {
className?: string
buttonClassName?: string
}) {
const {
photosCount,
uploadsCount,
tagsCount,
selectedPhotoIds,
setSelectedPhotoIds,
refreshAdminData,
clearAuthStateAndRedirect,
} = useAppState();
const isSelecting = selectedPhotoIds !== undefined;
const items: ComponentProps<typeof MoreMenu>['items'] = [{
label: 'Manage Photos',
...photosCount !== undefined && {
annotation: `${photosCount}`,
},
icon: <TbPhoto
size={15}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>,
href: PATH_ADMIN_PHOTOS,
}];
if (uploadsCount) {
items.push({
label: 'Uploads',
annotation: `${uploadsCount}`,
icon: <FaRegFolderOpen
size={16}
className="translate-y-[0.5px]"
/>,
href: PATH_ADMIN_UPLOADS,
});
}
if (tagsCount) {
items.push({
label: 'Manage Tags',
annotation: `${tagsCount}`,
icon: <FiTag
size={15}
className="translate-y-[0.5px]"
/>,
href: PATH_ADMIN_TAGS,
});
}
items.push({
label: 'App Info',
icon: <AdminAppInfoIcon
size="small"
className="translate-x-[-0.5px] translate-y-[-0.5px]"
/>,
href: PATH_ADMIN_INSIGHTS,
}, {
label: isSelecting
? 'Exit Select'
: 'Edit Multiple …',
icon: isSelecting
? <IoCloseSharp
className="text-[18px] translate-x-[-1px] translate-y-[1px]"
/>
: <ImCheckboxUnchecked
className="translate-x-[-0.5px] text-[0.75rem]"
/>,
href: PATH_GRID_INFERRED,
action: () => {
if (isSelecting) {
setSelectedPhotoIds?.(undefined);
} else {
setSelectedPhotoIds?.([]);
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
},
shouldPreventDefault: false,
}, {
label: 'Sign Out',
icon: <PiSignOutBold size={15} />,
action: () => signOutAction().then(clearAuthStateAndRedirect),
});
return (
<MoreMenu
items={[{
label: 'Insights',
icon: <span className="scale-90 translate-y-[-2px]">
<AdminAppInsightsIcon />
</span>,
href: PATH_ADMIN_INSIGHTS,
}, {
label: 'Configuration',
icon: <LuCog
className="text-[16px] translate-x-[0.5px]"
/>,
href: PATH_ADMIN_CONFIGURATION,
}, {
label: isSelecting
? 'Exit Select'
: 'Select',
icon: isSelecting
? <IoCloseSharp
className="text-[18px] translate-y-[-0.5px]"
/>
: <ImCheckboxUnchecked
className="text-[0.75rem] translate-x-[0.5px]"
/>,
href: PATH_GRID_INFERRED,
action: () => {
if (isSelecting) {
setSelectedPhotoIds?.(undefined);
} else {
setSelectedPhotoIds?.([]);
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
},
shouldPreventDefault: false,
}]}
header="Admin menu"
icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />}
align="start"
onOpen={refreshAdminData}
className={clsx(
'border-medium',
className,
)}
buttonClassName={clsx(
'rounded-none focus:outline-none',
buttonClassName,
)}
items={items}
ariaLabel="Admin Menu"
/>
);

View File

@ -150,7 +150,7 @@ export default function AdminBatchEditPanelClient({
selectedPhotoIds !== undefined
)
? <SiteGrid
className="sticky top-0 z-10 mb-5 -mt-2 pt-2"
className="sticky top-0 z-10 -mt-2 pt-2"
contentMain={<div className="flex flex-col gap-2">
<Note
color="gray"

View File

@ -0,0 +1,79 @@
'use client';
import { PATH_ADMIN_CONFIGURATION, PATH_ADMIN_INSIGHTS } from '@/app/paths';
import LinkWithStatus from '@/components/LinkWithStatus';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import clsx from 'clsx/lite';
import ClearCacheButton from '@/admin/ClearCacheButton';
import { usePathname } from 'next/navigation';
import { useAppState } from '@/state/AppState';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
const ADMIN_INFO_PAGES = [{
title: 'App Insights',
titleShort: 'Insights',
path: PATH_ADMIN_INSIGHTS,
}, {
title: 'Configuration',
titleShort: 'Config',
path: PATH_ADMIN_CONFIGURATION,
}];
const ADMIN_INFO_PAGE_WITHOUT_INSIGHTS = [{
title: 'App Configuration',
path: PATH_ADMIN_CONFIGURATION,
}] as typeof ADMIN_INFO_PAGES;
export default function AdminInfoPage({
includeInsights,
}: {
includeInsights: boolean
}) {
const pathname = usePathname();
const pages = includeInsights
? ADMIN_INFO_PAGES
: ADMIN_INFO_PAGE_WITHOUT_INSIGHTS;
const hasMultiplePages = pages.length > 1;
const { insightIndicatorStatus } = useAppState();
return (
<div className="flex items-center gap-4 min-h-9">
<div className={clsx(
'grow -translate-x-1',
'flex items-center gap-3',
)}>
{pages
.map(({ title, titleShort, path }) =>
<LinkWithStatus
key={path}
href={path}
className={clsx(
'relative',
hasMultiplePages
? pathname === path
? 'font-medium'
: 'text-dim'
: undefined,
'px-1 py-0.5 rounded-md',
'hover:text-main',
)}
loadingClassName="bg-gray-200/50 dark:bg-gray-700/50"
>
<ResponsiveText shortText={titleShort}>
{title}
</ResponsiveText>
{title === 'Insights' && insightIndicatorStatus &&
<InsightsIndicatorDot
size="small"
top={4}
right={-2}
/>}
</LinkWithStatus>)}
</div>
<ClearCacheButton />
</div>
);
}

View File

@ -3,28 +3,16 @@ import SiteGrid from '@/components/SiteGrid';
import { ReactNode } from 'react';
export default function AdminInfoPage({
title,
accessory,
children,
}: {
title: string
accessory?: ReactNode
children: ReactNode
}) {
return (
<SiteGrid
contentMain={
<div className="space-y-4">
<div className="flex items-center gap-4 min-h-9">
<div className="grow">
{title}
</div>
{accessory}
</div>
<Container spaceChildren={false}>
{children}
</Container>
</div>}
<Container spaceChildren={false}>
{children}
</Container>}
/>
);
}

View File

@ -14,20 +14,21 @@ import AdminNavClient from './AdminNavClient';
export default async function AdminNav() {
const [
countPhotos,
countUploads,
countTags,
countUploads,
mostRecentPhotoUpdateTime,
] = await Promise.all([
getPhotosMetaCached({ hidden: 'include' })
.then(({ count }) => count)
.catch(() => 0),
getUniqueTagsCached().then(tags => tags.length)
.catch(() => 0),
getStorageUploadUrlsNoStore()
.then(urls => urls.length)
.catch(e => {
console.error(`Error getting blob upload urls: ${e}`);
return 0;
}),
getUniqueTagsCached().then(tags => tags.length).catch(() => 0),
getPhotosMostRecentUpdateCached().catch(() => undefined),
]);

View File

@ -9,8 +9,7 @@ import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS,
checkPathPrefix,
isPathAdminConfiguration,
isPathAdminInsights,
isPathAdminInfo,
isPathTopLevelAdmin,
} from '@/app/paths';
import { useAppState } from '@/state/AppState';
@ -19,8 +18,8 @@ import { differenceInMinutes } from 'date-fns';
import { usePathname } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { FaRegClock } from 'react-icons/fa';
import AdminAppInsightsIcon from './insights/AdminAppInsightsIcon';
import { LuCog } from 'react-icons/lu';
import AdminAppInfoIcon from './AdminAppInfoIcon';
import AdminInfoNav from './AdminInfoNav';
// Updates considered recent if they occurred in past 5 minutes
const areTimesRecent = (dates: Date[]) => dates
@ -29,7 +28,7 @@ const areTimesRecent = (dates: Date[]) => dates
export default function AdminNavClient({
items,
mostRecentPhotoUpdateTime,
includeInsights,
includeInsights = true,
}: {
items: {
label: string,
@ -65,7 +64,7 @@ export default function AdminNavClient({
return (
<SiteGrid
contentMain={
<div className="space-y-5">
<div className="space-y-4">
<div className={clsx(
'flex gap-2 pb-3',
'border-b border-gray-200 dark:border-gray-800',
@ -82,8 +81,9 @@ export default function AdminNavClient({
'flex gap-0.5',
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
'px-1 py-0.5 rounded-md',
'hover:text-main',
)}
loadingClassName="bg-dim"
loadingClassName="bg-gray-200/50 dark:bg-gray-700/50"
prefetch={false}
>
<span>{label}</span>
@ -91,39 +91,25 @@ export default function AdminNavClient({
<span>({count})</span>}
</LinkWithStatus>)}
</div>
<div className="flex gap-3">
{includeInsights &&
<LinkWithLoader
href={PATH_ADMIN_INSIGHTS}
className={clsx(
'translate-y-[-2px]',
isPathAdminInsights(pathname)
? 'font-bold'
: 'text-dim')}
loader={<Spinner className="translate-y-[1px]" />}
>
<AdminAppInsightsIcon />
</LinkWithLoader>}
<LinkWithLoader
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
loader={<Spinner className="translate-y-[-0.75px]" />}
>
<LuCog
size={20}
className="inline-flex translate-y-[1px]"
aria-label="App Configuration"
/>
</LinkWithLoader>
</div>
<LinkWithLoader
href={includeInsights
? PATH_ADMIN_INSIGHTS
: PATH_ADMIN_CONFIGURATION}
className={isPathAdminInfo(pathname)
? 'font-bold'
: 'text-dim'}
loader={<Spinner className="translate-y-[-0.75px]" />}
>
<AdminAppInfoIcon />
</LinkWithLoader>
</div>
{shouldShowBanner &&
<Note icon={<FaRegClock className="shrink-0" />}>
Photo updates detectedthey may take several minutes to show up
for visitors
</Note>}
{isPathAdminInfo(pathname) &&
<AdminInfoNav {...{ includeInsights }} />}
</div>
}
/>

View File

@ -11,6 +11,7 @@ import { useState } from 'react';
import { syncPhotosAction } from '@/photo/actions';
import { useRouter } from 'next/navigation';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { LiaBroomSolid } from 'react-icons/lia';
const UPDATE_BATCH_SIZE_MAX = 4;
@ -77,16 +78,19 @@ export default function AdminOutdatedClient({
</LoaderButton>}
>
<div className="space-y-6">
<Note>
<Note
color="yellow"
icon={<LiaBroomSolid size={18}/>}
>
<div className="space-y-1.5">
<div className="font-bold">
{photos.length} outdated
{' '}
{photos.length === 1 ? 'photo' : 'photos'} found
</div>
They may have missing EXIF fields, inaccurate blur data,
Sync photos to import newer EXIF fields, improve blur data,
{' '}
undesired privacy settings, or text that can be AI-generated
and leverage AI-generated text where possible
</div>
</Note>
<div className="space-y-4">

View File

@ -23,7 +23,7 @@ import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';
import IconGrSync from '@/app/IconGrSync';
import { isPhotoOutdated } from '@/photo/outdated';
import { FaCircle } from 'react-icons/fa6';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
export default function AdminPhotoMenuClient({
photo,
@ -47,7 +47,10 @@ export default function AdminPhotoMenuClient({
const items = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Edit',
icon: <FaRegEdit size={14} />,
icon: <FaRegEdit
size={15}
className="translate-x-[0.5px] translate-y-[-0.5px]"
/>,
href: pathForAdminPhotoEdit(photo.id),
}];
if (includeFavorite) {
@ -78,12 +81,13 @@ export default function AdminPhotoMenuClient({
hrefDownloadName: downloadFileNameForPhoto(photo),
});
items.push({
label: <span className="inline-flex items-center gap-2">
label: 'Sync',
labelComplex: <span className="inline-flex items-center gap-2">
<span>Sync</span>
{isPhotoOutdated(photo) &&
<FaCircle
size={8}
className="text-amber-500 translate-y-[1.5px]"
<InsightsIndicatorDot
colorOverride="yellow"
className="translate-y-[1.5px]"
/>}
</span>,
icon: <IconGrSync className="translate-x-[-1px]" />,
@ -96,7 +100,7 @@ export default function AdminPhotoMenuClient({
size={15}
className="translate-x-[-1px]"
/>,
className: 'text-error',
className: 'text-error *:hover:text-error',
action: () => {
if (confirm(deleteConfirmationTextForPhoto(photo))) {
return deletePhotoAction(

View File

@ -6,6 +6,45 @@ import { testOpenAiConnection } from '@/platforms/openai';
import { testDatabaseConnection } from '@/platforms/postgres';
import { testStorageConnection } from '@/platforms/storage';
import { APP_CONFIGURATION } from '@/app/config';
import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
import { getPhotosMetaCached, getUniqueTagsCached } from '@/photo/cache';
import { getShouldShowInsightsIndicator } from '@/admin/insights/server';
export const getAdminDataAction = async () =>
runAuthenticatedAdminServerAction(async () => {
const [
countPhotos,
countHiddenPhotos,
countTags,
countUploads,
shouldShowInsightsIndicator,
] = await Promise.all([
getPhotosMetaCached()
.then(({ count }) => count)
.catch(() => 0),
getPhotosMetaCached({ hidden: 'only' })
.then(({ count }) => count)
.catch(() => 0),
getUniqueTagsCached()
.then(tags => tags.length)
.catch(() => 0),
getStorageUploadUrlsNoStore()
.then(urls => urls.length)
.catch(e => {
console.error(`Error getting blob upload urls: ${e}`);
return 0;
}),
getShouldShowInsightsIndicator(),
]);
return {
countPhotos,
countHiddenPhotos,
countTags,
countUploads,
shouldShowInsightsIndicator,
};
});
const scanForError = (
shouldCheck: boolean,

View File

@ -1,29 +0,0 @@
import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import { LuLightbulb } from 'react-icons/lu';
import { FaCircle } from 'react-icons/fa6';
export default function AdminAppInsightsIcon() {
const {
insightIndicatorStatus,
} = useAppState();
return (
<span className="inline-flex relative">
<LuLightbulb
size={18}
className="translate-y-[3.5px]"
/>
{insightIndicatorStatus &&
<FaCircle
size={8}
className={clsx(
'absolute',
'top-[2px] right-[0.5px]',
insightIndicatorStatus === 'blue'
? 'text-blue-500'
: 'text-amber-500',
)}
/>}
</span>
);
}

View File

@ -0,0 +1,50 @@
import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import { FaCircle } from 'react-icons/fa6';
export default function InsightsIndicatorDot({
className,
size = 'medium',
colorOverride,
top,
right,
bottom,
left,
}: {
className?: string
size?: 'small' | 'medium' | 'large'
colorOverride?: 'blue' | 'yellow'
top?: number
right?: number
bottom?: number
left?: number
}) {
const { insightIndicatorStatus } = useAppState();
const getSize = () => {
switch (size) {
case 'small': return 6;
case 'medium': return 7;
case 'large': return 8;
}
};
return (
<FaCircle
size={getSize()}
className={clsx(
(
top !== undefined ||
right !== undefined ||
bottom !== undefined ||
left !== undefined
) && 'absolute',
(colorOverride ?? insightIndicatorStatus) === 'blue'
? 'text-blue-500'
: 'text-amber-500',
className,
)}
style={{ top, right, bottom, left }}
/>
);
}

View File

@ -1,36 +0,0 @@
'use server';
import { runAuthenticatedAdminServerAction } from '@/auth';
import {
getGitHubMetaForCurrentApp,
getSignificantInsights,
InsightIndicatorStatus,
} from '.';
import { getOutdatedPhotosCount } from '@/photo/db/query';
export const getShouldShowInsightsIndicatorAction =
async (): Promise<InsightIndicatorStatus> =>
runAuthenticatedAdminServerAction(async () => {
const [
codeMeta,
photosCountOutdated,
] = await Promise.all([
getGitHubMetaForCurrentApp(),
getOutdatedPhotosCount(),
]);
const {
forkBehind,
noAiRateLimiting,
outdatedPhotos,
} = getSignificantInsights({
codeMeta,
photosCountOutdated,
});
if (noAiRateLimiting || outdatedPhotos) {
return 'yellow';
} else if (forkBehind) {
return 'blue';
}
});

View File

@ -0,0 +1,28 @@
import { getOutdatedPhotosCount } from '@/photo/db/query';
import { getSignificantInsights } from '.';
import { getGitHubMetaForCurrentApp } from '.';
export const getShouldShowInsightsIndicator = async () => {
const [
codeMeta,
photosCountOutdated,
] = await Promise.all([
getGitHubMetaForCurrentApp(),
getOutdatedPhotosCount(),
]);
const {
forkBehind,
noAiRateLimiting,
outdatedPhotos,
} = getSignificantInsights({
codeMeta,
photosCountOutdated,
});
if (noAiRateLimiting || outdatedPhotos) {
return 'yellow';
} else if (forkBehind) {
return 'blue';
}
};

View File

@ -9,7 +9,7 @@ import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation';
import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { signOutAndRedirectAction } from '@/auth/actions';
import { signOutAction } from '@/auth/actions';
import Spinner from '@/components/Spinner';
import AnimateItems from '@/components/AnimateItems';
import { useAppState } from '@/state/AppState';
@ -17,7 +17,7 @@ import { useAppState } from '@/state/AppState';
export default function Footer() {
const pathname = usePathname();
const { userEmail, setUserEmail } = useAppState();
const { userEmail, clearAuthStateAndRedirect } = useAppState();
const showFooter = !isPathSignIn(pathname);
@ -48,8 +48,8 @@ export default function Footer() {
)}>
{userEmail}
</div>
<form action={() => signOutAndRedirectAction()
.then(() => setUserEmail?.(undefined))}>
<form action={() => signOutAction()
.then(clearAuthStateAndRedirect)}>
<SubmitButtonWithStatus styleAs="link">
Sign out
</SubmitButtonWithStatus>

View File

@ -14,13 +14,11 @@ import {
isPathSignIn,
} from '@/app/paths';
import AnimateItems from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';
import {
GRID_HOMEPAGE_ENABLED,
HAS_DEFINED_SITE_DESCRIPTION,
SITE_DESCRIPTION,
} from './config';
import AdminAppMenu from '@/admin/AdminAppMenu';
const NAV_HEIGHT_CLASS = HAS_DEFINED_SITE_DESCRIPTION
? 'min-h-[4rem] sm:min-h-[5rem]'
@ -33,8 +31,6 @@ export default function Nav({
}) {
const pathname = usePathname();
const { isUserSignedIn } = useAppState();
const showNav = !isPathSignIn(pathname);
const renderLink = (
@ -73,7 +69,6 @@ export default function Nav({
)}>
<ViewSwitcher
currentSelection={switcherSelectionForPath()}
showAdmin={isUserSignedIn}
/>
<div className={clsx(
'grow text-right min-w-0',
@ -98,16 +93,6 @@ export default function Nav({
: []}
/>
}
contentSide={isUserSignedIn && !isPathAdmin(pathname)
? <div
className={clsx(
'flex items-center translate-x-[-6px] w-full',
NAV_HEIGHT_CLASS,
)}
>
<AdminAppMenu />
</div>
: undefined}
sideHiddenOnMobile
/>
);

View File

@ -3,27 +3,30 @@ import SwitcherItem from '@/components/SwitcherItem';
import IconFeed from '@/app/IconFeed';
import IconGrid from '@/app/IconGrid';
import {
PATH_ADMIN_PHOTOS,
PATH_FEED_INFERRED,
PATH_GRID_INFERRED,
} from '@/app/paths';
import { BiLockAlt } from 'react-icons/bi';
import IconSearch from './IconSearch';
import { useAppState } from '@/state/AppState';
import { GRID_HOMEPAGE_ENABLED } from './config';
import AdminAppMenu from '@/admin/AdminAppMenu';
import { clsx } from 'clsx/lite';
import Spinner from '@/components/Spinner';
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
export default function ViewSwitcher({
currentSelection,
showAdmin,
}: {
currentSelection?: SwitcherSelection
showAdmin?: boolean
}) {
const { setIsCommandKOpen } = useAppState();
const {
isUserSignedIn,
isUserSignedInEager,
setIsCommandKOpen,
} = useAppState();
const renderItemFeed = () =>
const renderItemFeed =
<SwitcherItem
icon={<IconFeed />}
href={PATH_FEED_INFERRED}
@ -31,7 +34,7 @@ export default function ViewSwitcher({
noPadding
/>;
const renderItemGrid = () =>
const renderItemGrid =
<SwitcherItem
icon={<IconGrid />}
href={PATH_GRID_INFERRED}
@ -42,13 +45,29 @@ export default function ViewSwitcher({
return (
<div className="flex gap-1 sm:gap-2">
<Switcher>
{GRID_HOMEPAGE_ENABLED ? renderItemGrid() : renderItemFeed()}
{GRID_HOMEPAGE_ENABLED ? renderItemFeed() : renderItemGrid()}
{showAdmin &&
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFeed}
{GRID_HOMEPAGE_ENABLED ? renderItemFeed : renderItemGrid}
{/* Show spinner if admin is suspected to be logged in */}
{(isUserSignedInEager && !isUserSignedIn) &&
<SwitcherItem
icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />}
href={PATH_ADMIN_PHOTOS}
active={currentSelection === 'admin'}
icon={<Spinner />}
isInteractive={false}
noPadding
/>}
{isUserSignedIn &&
<SwitcherItem
icon={<AdminAppMenu
className="mt-3 ml-[-94px]"
buttonClassName={clsx(
'bg-transparent dark:bg-transparent',
'hover:bg-transparent dark:hover:bg-transparent',
'active:bg-transparent dark:active:bg-transparent',
currentSelection === 'admin'
? 'text-black dark:text-white'
: 'text-gray-400 dark:text-gray-600',
)}
/>}
noPadding
/>}
</Switcher>
<Switcher type="borderless">

View File

@ -64,6 +64,7 @@ export const PATHS_ADMIN = [
PATH_ADMIN_PHOTOS,
PATH_ADMIN_UPLOADS,
PATH_ADMIN_TAGS,
PATH_ADMIN_INSIGHTS,
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_BASELINE,
PATH_ADMIN_COMPONENTS,
@ -225,11 +226,15 @@ export const isPathAdmin = (pathname?: string) =>
export const isPathTopLevelAdmin = (pathname?: string) =>
PATHS_ADMIN.some(path => path === pathname);
export const isPathAdminInsights = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_INSIGHTS);
export const isPathAdminConfiguration = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION);
export const isPathAdminInsights = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_INSIGHTS);
export const isPathAdminInfo = (pathname?: string) =>
isPathAdminInsights(pathname) ||
isPathAdminConfiguration(pathname);
export const isPathProtected = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN) ||

View File

@ -10,7 +10,7 @@ import {
signIn,
signOut,
} from '@/auth';
import { PATH_ADMIN_PHOTOS, PATH_ROOT } from '@/app/paths';
import { PATH_ADMIN_PHOTOS, PATH_SIGN_IN } from '@/app/paths';
import type { Session } from 'next-auth';
import { redirect } from 'next/navigation';
@ -41,8 +41,11 @@ export const signInAction = async (
redirect(formData.get(KEY_CALLBACK_URL) as string || PATH_ADMIN_PHOTOS);
};
export const signOutAndRedirectAction = async () =>
signOut({ redirectTo: PATH_ROOT });
export const signOutAction = async () =>
signOut({ redirect: false });
export const signOutAndRedirectAction = async (redirectTo = PATH_SIGN_IN) =>
signOut({ redirectTo });
export const getAuthAction = async () => auth();

12
src/auth/client.ts Normal file
View File

@ -0,0 +1,12 @@
import { deleteCookie, getCookie, storeCookie } from '@/utility/cookie';
const KEY_AUTH_EMAIL = 'authjs.email';
export const storeAuthEmailCookie = (email: string) =>
storeCookie(KEY_AUTH_EMAIL, email);
export const clearAuthEmailCookie = () =>
deleteCookie(KEY_AUTH_EMAIL);
export const hasAuthEmailCookie = () =>
Boolean(getCookie(KEY_AUTH_EMAIL));

View File

@ -4,14 +4,14 @@ import { ReactNode } from 'react';
export default function Container({
children,
className,
color = 'gray',
color = 'gray-border',
padding = 'normal',
centered = true,
spaceChildren = true,
}: {
children: ReactNode
className?: string
color?: 'gray' | 'blue' | 'red' | 'yellow'
color?: 'gray' | 'gray-border' | 'blue' | 'red' | 'yellow'
padding?:
'loose' |
'normal' |
@ -24,24 +24,25 @@ export default function Container({
const getColorClasses = () => {
switch (color) {
case 'gray': return [
'text-medium',
'bg-dim',
];
case 'gray-border': return [
'text-medium',
'bg-gray-50 dark:bg-gray-900/40',
'border-gray-200 dark:border-gray-800',
'border border-gray-200 dark:border-gray-800',
];
case 'blue': return [
'text-blue-900 dark:text-blue-300',
'bg-blue-50/50 dark:bg-blue-950/30',
'border-blue-200 dark:border-blue-500/40',
'bg-blue-100/35 dark:bg-blue-950/60',
];
case 'red': return [
'text-red-600 dark:text-red-500/90',
'bg-red-50/50 dark:bg-red-950/50',
'border-red-100 dark:border-red-950',
'text-red-700 dark:text-red-400',
'bg-red-100/50 dark:bg-red-950/55',
];
case 'yellow': return [
'text-amber-700 dark:text-amber-500/90',
'bg-amber-50/50 dark:bg-amber-950/30',
'border-amber-600/30 dark:border-amber-800/30',
'text-amber-700 dark:text-amber-500',
'bg-amber-100/55 dark:bg-amber-950/55',
];
}
};
@ -59,7 +60,7 @@ export default function Container({
return (
<div className={clsx(
'flex flex-col items-center justify-center',
'rounded-lg border',
'rounded-lg',
...getColorClasses(),
getPaddingClasses(),
className,

View File

@ -24,6 +24,7 @@ export type LinkWithStatusProps = Omit<
children: ReactNode | ((props: {
isLoading: boolean
}) => ReactNode)
debugLoading?: boolean
}
export default function LinkWithStatus({
@ -32,12 +33,14 @@ export default function LinkWithStatus({
className,
onClick,
children,
debugLoading = false,
...props
}: LinkWithStatusProps) {
const path = usePathname();
const [pathWhenClicked, setPathWhenClicked] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [_isLoading, setIsLoading] = useState(false);
const isLoading = _isLoading || debugLoading;
const isLoadingStartTime = useRef<number | undefined>(undefined);

View File

@ -11,10 +11,10 @@ export default function Switcher({
return (
<div className={clsx(
'flex divide-x overflow-hidden',
'divide-gray-300 dark:divide-gray-800',
'divide-medium',
'border rounded-md',
type === 'regular'
? 'border-gray-300 dark:border-gray-800'
? 'border-medium'
: 'border-transparent',
type === 'regular' && 'shadow-xs',
)}>

View File

@ -11,6 +11,7 @@ export default function SwitcherItem({
className: classNameProp,
onClick,
active,
isInteractive = true,
noPadding,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
}: {
@ -20,21 +21,24 @@ export default function SwitcherItem({
className?: string
onClick?: () => void
active?: boolean
isInteractive?: boolean
noPadding?: boolean
prefetch?: boolean
}) {
const className = clsx(
classNameProp,
'flex items-center justify-center',
'w-[42px] h-full',
'py-0.5 px-1.5',
'cursor-pointer',
'hover:bg-gray-100/60 active:bg-gray-100',
'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
isInteractive && 'cursor-pointer',
isInteractive && 'hover:bg-gray-100/60 active:bg-gray-100',
isInteractive && 'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
active
? 'text-black dark:text-white'
: 'text-gray-400 dark:text-gray-600',
active
? 'hover:text-black dark:hover:text-white'
: 'hover:text-gray-700 dark:hover:text-gray-400',
classNameProp,
);
const renderIcon = () => noPadding
@ -54,6 +58,8 @@ export default function SwitcherItem({
}}>
{renderIcon()}
</LinkWithLoader>
: <div {...{ title, onClick, className }}>{renderIcon()}</div>
: <div {...{ title, onClick, className }}>
{renderIcon()}
</div>
);
};

View File

@ -39,12 +39,12 @@ import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAndRedirectAction } from '@/auth/actions';
import { signOutAction } from '@/auth/actions';
import { TbPhoto } from 'react-icons/tb';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate';
import PhotoSmall from '@/photo/PhotoSmall';
import { FaCheck, FaCircle } from 'react-icons/fa6';
import { FaCheck } from 'react-icons/fa6';
import { Tags, addHiddenToTags, formatTag } from '@/tag';
import { FaTag } from 'react-icons/fa';
import { formatCount, formatCountDescriptive } from '@/utility/string';
@ -52,6 +52,7 @@ import CommandKItem from './CommandKItem';
import { GRID_HOMEPAGE_ENABLED } from '@/app/config';
import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
import InsightsIndicatorDot from '@/admin/insights/InsightsIndicatorDot';
const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -100,9 +101,11 @@ export default function CommandKClient({
const {
isUserSignedIn,
setUserEmail,
clearAuthStateAndRedirect,
isCommandKOpen: isOpen,
hiddenPhotosCount,
photosCountHidden,
uploadsCount,
tagsCount,
selectedPhotoIds,
setSelectedPhotoIds,
insightIndicatorStatus,
@ -228,8 +231,8 @@ export default function CommandKClient({
}, [isOpen, setShouldRespondToKeyboardCommands]);
const tagsIncludingHidden = useMemo(() =>
addHiddenToTags(tags, hiddenPhotosCount)
, [tags, hiddenPhotosCount]);
addHiddenToTags(tags, photosCountHidden)
, [tags, photosCountHidden]);
const SECTION_TAGS: CommandKSection = {
heading: 'Tags',
@ -336,70 +339,75 @@ export default function CommandKClient({
const adminSection: CommandKSection = {
heading: 'Admin',
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
items: isUserSignedIn
? ([{
label: 'Manage Photos',
annotation: <BiLockAlt />,
path: PATH_ADMIN_PHOTOS,
}, {
items: [],
};
if (isUserSignedIn) {
adminSection.items.push({
label: 'Manage Photos',
annotation: <BiLockAlt />,
path: PATH_ADMIN_PHOTOS,
});
if (uploadsCount) {
adminSection.items.push({
label: 'Manage Uploads',
annotation: <BiLockAlt />,
path: PATH_ADMIN_UPLOADS,
}, {
});
}
if (tagsCount) {
adminSection.items.push({
label: 'Manage Tags',
annotation: <BiLockAlt />,
path: PATH_ADMIN_TAGS,
}, {
label: 'App Config',
});
}
adminSection.items.push({
label: <span className="flex items-center gap-3">
App Insights
{insightIndicatorStatus &&
<InsightsIndicatorDot />}
</span>,
keywords: ['app insights'],
annotation: <BiLockAlt />,
path: PATH_ADMIN_INSIGHTS,
}, {
label: 'App Config',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
}, {
label: selectedPhotoIds === undefined
? 'Select Multiple Photos'
: 'Exit Select Multiple Photos',
annotation: <BiLockAlt />,
path: selectedPhotoIds === undefined
? PATH_GRID_INFERRED
: undefined,
action: selectedPhotoIds === undefined
? () => setSelectedPhotoIds?.([])
: () => setSelectedPhotoIds?.(undefined),
});
if (showDebugTools) {
adminSection.items.push({
label: 'Baseline Overview',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
path: PATH_ADMIN_BASELINE,
}, {
label: <span className="flex items-center gap-3">
App Insights
{insightIndicatorStatus && <FaCircle
size={8}
className={clsx(
insightIndicatorStatus === 'blue'
? 'text-blue-500'
: 'text-amber-500',
)}
/>}
</span>,
keywords: ['app insights'],
label: 'Components Overview',
annotation: <BiLockAlt />,
path: PATH_ADMIN_INSIGHTS,
}, {
label: selectedPhotoIds === undefined
? 'Select Multiple Photos'
: 'Exit Select Multiple Photos',
annotation: <BiLockAlt />,
path: selectedPhotoIds === undefined
? PATH_GRID_INFERRED
: undefined,
action: selectedPhotoIds === undefined
? () => setSelectedPhotoIds?.([])
: () => setSelectedPhotoIds?.(undefined),
}] as CommandKItem[])
.concat(showDebugTools
? [{
label: 'Baseline Overview',
path: PATH_ADMIN_BASELINE,
}, {
label: 'Components Overview',
path: PATH_ADMIN_COMPONENTS,
}]
: [])
.concat({
label: 'Sign Out',
action: () => {
signOutAndRedirectAction().then(() => setUserEmail?.(undefined));
},
})
: [{
label: 'Sign In',
path: PATH_SIGN_IN,
}],
};
path: PATH_ADMIN_COMPONENTS,
});
}
adminSection.items.push({
label: 'Sign Out',
action: () => signOutAction().then(clearAuthStateAndRedirect),
});
} else {
adminSection.items.push({
label: 'Sign In',
path: PATH_SIGN_IN,
});
}
return (
<Command.Dialog

View File

@ -1,4 +1,10 @@
import { ComponentProps } from 'react';
import {
ComponentProps,
ReactNode,
useCallback,
useEffect,
useState,
} from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { FiMoreHorizontal } from 'react-icons/fi';
@ -6,47 +12,78 @@ import MoreMenuItem from './MoreMenuItem';
export default function MoreMenu({
items,
icon,
header,
className,
buttonClassName,
ariaLabel,
align = 'end',
onOpen,
...props
}: {
items: ComponentProps<typeof MoreMenuItem> []
items: ComponentProps<typeof MoreMenuItem>[]
icon?: ReactNode
header?: ReactNode
className?: string
buttonClassName?: string
ariaLabel: string
}){
onOpen?: () => void
} & ComponentProps<typeof DropdownMenu.Content>){
const [isOpen, setIsOpen] = useState(false);
const dismissMenu = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
useEffect(() => {
if (isOpen) { onOpen?.(); }
}, [isOpen, onOpen]);
return (
<DropdownMenu.Root>
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger asChild>
<button
className={clsx(
buttonClassName,
'p-1 min-h-0 border-none shadow-none hover:outline-hidden',
'hover:bg-gray-100 active:bg-gray-100',
'dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
'text-dim',
buttonClassName,
)}
aria-label={ariaLabel}
>
<FiMoreHorizontal size={18} />
{icon ?? <FiMoreHorizontal size={18} />}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
{...props}
align={align}
className={clsx(
'z-10',
'min-w-[8rem]',
'ml-2.5',
'component-surface',
'p-1',
'shadow-lg dark:shadow-xl',
'shadow-lg',
'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=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top',
className,
)}
>
{header && <div className={clsx(
'px-2 pt-3 pb-2 text-dim uppercase',
'text-sm',
)}>
{header}
</div>}
{items.map(props =>
<MoreMenuItem key={`${props.label}`} {...props} />,
<MoreMenuItem
key={`${props.label}`}
{...props}
dismissMenu={dismissMenu}
/>,
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>

View File

@ -2,26 +2,32 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { ReactNode, useState, useTransition } from 'react';
import { ReactNode, useEffect, useState, useTransition } from 'react';
import LoaderButton from '../primitives/LoaderButton';
import { usePathname, useRouter } from 'next/navigation';
import { downloadFileFromBrowser } from '@/utility/url';
export default function MoreMenuItem({
label,
labelComplex,
annotation,
icon,
href,
hrefDownloadName,
className,
action,
dismissMenu,
shouldPreventDefault = true,
}: {
label: ReactNode
label: string
labelComplex?: ReactNode
annotation?: string
icon?: ReactNode
href?: string
hrefDownloadName?: string
className?: string
action?: () => Promise<void> | void
dismissMenu?: () => void
shouldPreventDefault?: boolean
}) {
const router = useRouter();
@ -30,38 +36,54 @@ export default function MoreMenuItem({
const [isPending, startTransition] = useTransition();
const [transitionDidStart, setTransitionDidStart] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (transitionDidStart && !isPending) {
dismissMenu?.();
setTransitionDidStart(false);
}
}, [isPending, dismissMenu, transitionDidStart]);
return (
<DropdownMenu.Item
disabled={isLoading}
className={clsx(
'flex items-center h-8',
'px-2 py-1.5 rounded-[3px]',
'flex items-center h-9',
'pl-2 pr-3 py-2 rounded-sm',
'select-none hover:outline-hidden',
'hover:bg-gray-50 active:bg-gray-100',
'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
'hover:bg-gray-100/90 active:bg-gray-200/75',
'dark:hover:bg-gray-800/60 dark:active:bg-gray-900/80',
'whitespace-nowrap',
isLoading
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
className,
)}
onClick={async e => {
onSelect={async e => {
if (shouldPreventDefault) { e.preventDefault(); }
if (action) {
const result = action();
if (result instanceof Promise) {
setIsLoading(true);
await result.finally(() => setIsLoading(false));
await result.finally(() => {
setIsLoading(false);
dismissMenu?.();
});
}
}
if (href && href !== pathname) {
if (hrefDownloadName) {
setIsLoading(true);
downloadFileFromBrowser(href, hrefDownloadName)
.finally(() => setIsLoading(false));
.finally(() => {
setIsLoading(false);
dismissMenu?.();
});
} else {
setTransitionDidStart(true);
startTransition(() => router.push(href));
}
}
@ -74,7 +96,11 @@ export default function MoreMenuItem({
styleAs="link-without-hover"
className="translate-y-[1px]"
>
{label}
{labelComplex ?? label}
{annotation &&
<span className="text-dim ml-3">
{annotation}
</span>}
</LoaderButton>
</DropdownMenu.Item>
);

View File

@ -37,11 +37,11 @@ export default function PhotoGridSidebar({
}) {
const { start, end } = dateRangeForPhotos(undefined, photosDateRange);
const { hiddenPhotosCount } = useAppState();
const { photosCountHidden } = useAppState();
const tagsIncludingHidden = useMemo(() =>
addHiddenToTags(tags, hiddenPhotosCount)
, [tags, hiddenPhotosCount]);
addHiddenToTags(tags, photosCountHidden)
, [tags, photosCountHidden]);
return (
<div className="space-y-4">

View File

@ -22,7 +22,6 @@ import { redirect } from 'next/navigation';
import { deleteFile } from '@/platforms/storage';
import {
getPhotosCached,
getPhotosMetaCached,
revalidateAdminPaths,
revalidateAllKeysAndPaths,
revalidatePhoto,
@ -416,10 +415,6 @@ export const streamAiImageQueryAction = async (
export const getImageBlurAction = async (url: string) =>
runAuthenticatedAdminServerAction(() => blurImageFromUrl(url));
export const getPhotosHiddenMetaCachedAction = async () =>
runAuthenticatedAdminServerAction(() =>
getPhotosMetaCached({ hidden: 'only' }));
// Public/Private actions
export const getPhotosAction = async (

View File

@ -197,7 +197,6 @@ export const formHasTextContent = ({
// CREATE FORM DATA: FROM PHOTO
export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
console.log('convertPhotoToFormData', photo);
const valueForKey = (key: keyof Photo, value: any) => {
switch (key) {
case 'tags':

View File

@ -2,7 +2,6 @@ import {
TEMPLATE_REPO_OWNER,
TEMPLATE_REPO_NAME,
TEMPLATE_REPO_BRANCH,
IS_DEVELOPMENT,
} from '@/app/config';
const DEFAULT_BRANCH = 'main';
@ -17,7 +16,7 @@ interface RepoParams {
const fetchGitHub = async (
url: string,
cacheRequest = IS_DEVELOPMENT,
cacheRequest = true,
) => {
const data = await fetch(
url,
@ -133,8 +132,6 @@ export const getGitHubPublicFork = async (): Promise<RepoParams> => {
};
export const getGitHubMeta = async (params: RepoParams) => {
console.log('getGitHubMeta', params);
const urlOwner = getGitHubUrlOwner(params);
const urlRepo = getGitHubUrlRepo(params);
const urlBranch = getGitHubUrlBranch(params);

View File

@ -20,13 +20,20 @@ export interface AppStateContext {
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
shareModalProps?: ShareModalProps
setShareModalProps?: Dispatch<SetStateAction<ShareModalProps | undefined>>
// ADMIN
// AUTH
userEmail?: string
setUserEmail?: Dispatch<SetStateAction<string | undefined>>
isUserSignedIn?: boolean
isUserSignedInEager?: boolean
clearAuthStateAndRedirect?: () => void
// ADMIN
adminUpdateTimes?: Date[]
registerAdminUpdate?: () => void
hiddenPhotosCount?: number
refreshAdminData?: () => void
photosCount?: number
photosCountHidden?: number
uploadsCount?: number
tagsCount?: number
selectedPhotoIds?: string[]
setSelectedPhotoIds?: Dispatch<SetStateAction<string[] | undefined>>
isPerformingSelectEdit?: boolean

View File

@ -12,11 +12,17 @@ import {
MATTE_PHOTOS,
SHOW_ZOOM_CONTROLS,
} from '@/app/config';
import { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
import { ShareModalProps } from '@/share';
import { storeTimezoneCookie } from '@/utility/timezone';
import { getShouldShowInsightsIndicatorAction } from '@/admin/insights/actions';
import { InsightIndicatorStatus } from '@/admin/insights';
import { getAdminDataAction } from '@/admin/actions';
import {
storeAuthEmailCookie,
clearAuthEmailCookie,
hasAuthEmailCookie,
} from '@/auth/client';
import { useRouter } from 'next/navigation';
import { PATH_SIGN_IN } from '@/app/paths';
export default function AppStateProvider({
children,
@ -25,6 +31,8 @@ export default function AppStateProvider({
}) {
const { previousPathname } = usePathnames();
const router = useRouter();
// CORE
const [hasLoaded, setHasLoaded] =
useState(false);
@ -42,10 +50,19 @@ export default function AppStateProvider({
// ADMIN
const [userEmail, setUserEmail] =
useState<string>();
const [isUserSignedInEager, setIsUserSignedInEager] =
useState(false);
// ADMIN
const [adminUpdateTimes, setAdminUpdateTimes] =
useState<Date[]>([]);
const [hiddenPhotosCount, setHiddenPhotosCount] =
useState(0);
const [photosCount, setPhotosCount] =
useState<number>();
const [photosCountHidden, setPhotosCountHidden] =
useState<number>();
const [uploadsCount, setUploadsCount] =
useState<number>();
const [tagsCount, setTagsCount] =
useState<number>();
const [selectedPhotoIds, setSelectedPhotoIds] =
useState<string[] | undefined>();
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
@ -70,26 +87,43 @@ export default function AppStateProvider({
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const { data, error } = useSWR('getAuth', getAuthAction);
const { data: auth, error: authError } = useSWR('getAuth', getAuthAction);
useEffect(() => {
if (!error) {
setUserEmail(data?.user?.email ?? undefined);
setIsUserSignedInEager(hasAuthEmailCookie());
if (!authError) {
setUserEmail(auth?.user?.email ?? undefined);
}
}, [data, error]);
}, [auth, authError]);
const isUserSignedIn = Boolean(userEmail);
const {
data: adminData,
error: adminError,
mutate: refreshAdminData,
} = useSWR(
isUserSignedIn ? 'getAdminData' : null,
getAdminDataAction, {
refreshInterval: 1000 * 60,
},
);
useEffect(() => {
if (isUserSignedIn) {
const timeout = setTimeout(() =>{
getPhotosHiddenMetaCachedAction()
.then(({ count }) => setHiddenPhotosCount(count));
getShouldShowInsightsIndicatorAction()
.then(setInsightIndicatorStatus);
}, 100);
return () => clearTimeout(timeout);
if (userEmail) {
storeAuthEmailCookie(userEmail);
if (adminData) {
const timeout = setTimeout(() => {
setPhotosCount(adminData.countPhotos);
setPhotosCountHidden(adminData.countHiddenPhotos);
setUploadsCount(adminData.countUploads);
setTagsCount(adminData.countTags);
setInsightIndicatorStatus(adminData.shouldShowInsightsIndicator);
}, 100);
return () => clearTimeout(timeout);
}
} else {
setHiddenPhotosCount(0);
setPhotosCountHidden(0);
}
}, [isUserSignedIn]);
}, [adminData, adminError, userEmail]);
const registerAdminUpdate = useCallback(() =>
setAdminUpdateTimes(updates => [...updates, new Date()])
@ -100,6 +134,12 @@ export default function AppStateProvider({
storeTimezoneCookie();
}, []);
const clearAuthStateAndRedirect = useCallback((shouldRedirect = true) => {
setUserEmail(undefined);
clearAuthEmailCookie();
if (shouldRedirect) { router.push(PATH_SIGN_IN); }
}, [router]);
return (
<AppStateContext.Provider
value={{
@ -119,13 +159,20 @@ export default function AppStateProvider({
setIsCommandKOpen,
shareModalProps,
setShareModalProps,
// ADMIN
// AUTH
userEmail,
setUserEmail,
isUserSignedIn,
isUserSignedInEager,
clearAuthStateAndRedirect,
// ADMIN
adminUpdateTimes,
registerAdminUpdate,
hiddenPhotosCount,
refreshAdminData,
photosCount,
photosCountHidden,
uploadsCount,
tagsCount,
selectedPhotoIds,
setSelectedPhotoIds,
isPerformingSelectEdit,

View File

@ -105,11 +105,11 @@ export const isPathFavs = (pathname?: string) =>
export const isTagHidden = (tag: string) => tag.toLowerCase() === TAG_HIDDEN;
export const addHiddenToTags = (tags: Tags, hiddenPhotosCount = 0) => {
if (hiddenPhotosCount > 0) {
export const addHiddenToTags = (tags: Tags, photosCountHidden = 0) => {
if (photosCountHidden > 0) {
return tags
.filter(({ tag }) => tag === TAG_FAVS)
.concat({ tag: TAG_HIDDEN, count: hiddenPhotosCount })
.concat({ tag: TAG_HIDDEN, count: photosCountHidden })
.concat(tags.filter(({ tag }) => tag !== TAG_FAVS));
} else {
return tags;

View File

@ -5,19 +5,25 @@ export const storeCookie = (
maxAge = 63158400,
sameSite = 'Lax',
) => {
document.cookie =
`${name}=${value};Path=${path};Max-Age=${maxAge};SameSite=${sameSite}`;
if (typeof document !== 'undefined') {
document.cookie =
`${name}=${value};Path=${path};Max-Age=${maxAge};SameSite=${sameSite}`;
}
};
export const getCookie = (name: string) => {
const cookie: Record<string, string> = {};
document.cookie.split(';').forEach(function(el) {
const split = el.split('=');
cookie[split[0].trim()] = split.slice(1).join('=');
});
return cookie[name];
if (typeof document !== 'undefined') {
const cookie: Record<string, string> = {};
document.cookie.split(';').forEach(function(el) {
const split = el.split('=');
cookie[split[0].trim()] = split.slice(1).join('=');
});
return cookie[name];
}
};
export const deleteCookie = (name: string) => {
document.cookie = `${name}=;Max-Age=0`;
if (typeof document !== 'undefined') {
document.cookie = `${name}=;Max-Age=0`;
}
};

View File

@ -301,6 +301,7 @@
disabled:bg-gray-100 dark:disabled:bg-gray-900
disabled:border-gray-200 disabled:dark:border-gray-700
border-gray-900 dark:border-gray-100
hover:border-gray-900 dark:hover:border-gray-100
active:bg-gray-700 active:border-gray-700
active:dark:bg-gray-300 active:dark:border-gray-300
shadow-none