commit
7df506abdb
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -6,6 +6,7 @@
|
||||
"ARROWLEFT",
|
||||
"ARROWRIGHT",
|
||||
"Astia",
|
||||
"authjs",
|
||||
"camelcase",
|
||||
"cloudflarestorage",
|
||||
"cmdk",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
20
package.json
20
package.json
@ -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
484
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
33
src/admin/AdminAppInfoIcon.tsx
Normal file
33
src/admin/AdminAppInfoIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
79
src/admin/AdminInfoNav.tsx
Normal file
79
src/admin/AdminInfoNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@ -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 detected—they may take several minutes to show up
|
||||
for visitors
|
||||
</Note>}
|
||||
{isPathAdminInfo(pathname) &&
|
||||
<AdminInfoNav {...{ includeInsights }} />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
50
src/admin/insights/InsightsIndicatorDot.tsx
Normal file
50
src/admin/insights/InsightsIndicatorDot.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
});
|
||||
28
src/admin/insights/server.ts
Normal file
28
src/admin/insights/server.ts
Normal 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';
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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
12
src/auth/client.ts
Normal 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));
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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',
|
||||
)}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user