Refine admin app insights data

This commit is contained in:
Sam Becker 2025-02-13 17:12:45 -06:00
parent 556fa62b08
commit 89c985497c
9 changed files with 225 additions and 104 deletions

View File

@ -1,11 +1,11 @@
import { getGitHubMetaWithFallback, getGitHubPublicFork } from '@/admin/github'; import { getGitHubMetaWithFallback, getGitHubPublicFork } from '@/admin/github';
import { TEMPLATE_BASE_OWNER, TEMPLATE_BASE_REPO } from '@/app-core/config'; import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_NAME } from '@/app-core/config';
describe('GitHub', () => { describe('GitHub', () => {
it('fetches base repo meta', async () => { it('fetches base repo meta', async () => {
const meta = await getGitHubMetaWithFallback({ const meta = await getGitHubMetaWithFallback({
owner: TEMPLATE_BASE_OWNER, owner: TEMPLATE_REPO_OWNER,
repo: TEMPLATE_BASE_REPO, repo: TEMPLATE_REPO_NAME,
}); });
expect(meta).toBeDefined(); expect(meta).toBeDefined();
expect(meta.url).toBeDefined(); expect(meta.url).toBeDefined();

View File

@ -6,17 +6,32 @@ import {
getUniqueTags, getUniqueTags,
} from '@/photo/db/query'; } from '@/photo/db/query';
import AdminAppInsightsClient from './AdminAppInsightsClient'; import AdminAppInsightsClient from './AdminAppInsightsClient';
import { APP_CONFIGURATION } from '@/app-core/config'; import {
APP_CONFIGURATION,
IS_VERCEL_GIT_PROVIDER_GITHUB,
VERCEL_GIT_BRANCH,
VERCEL_GIT_COMMIT_SHA,
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
} from '@/app-core/config';
import { getGitHubMetaWithFallback } from './github';
const owner = VERCEL_GIT_REPO_OWNER;
const repo = VERCEL_GIT_REPO_SLUG;
const branch = VERCEL_GIT_BRANCH;
const commit = VERCEL_GIT_COMMIT_SHA;
export default async function AdminAppInsights() { export default async function AdminAppInsights() {
const [ const [
{ count, dateRange }, { count, dateRange },
{ count: countHidden },
tags, tags,
cameras, cameras,
filmSimulations, filmSimulations,
lenses, lenses,
] = await Promise.all([ ] = await Promise.all([
getPhotosMeta({ hidden: 'include' }), getPhotosMeta({ hidden: 'include' }),
getPhotosMeta({ hidden: 'only' }),
getUniqueTags(), getUniqueTags(),
getUniqueCameras(), getUniqueCameras(),
getUniqueFilmSimulations(), getUniqueFilmSimulations(),
@ -28,16 +43,29 @@ export default async function AdminAppInsights() {
hasVercelBlobStorage, hasVercelBlobStorage,
} = APP_CONFIGURATION; } = APP_CONFIGURATION;
const codeMeta = IS_VERCEL_GIT_PROVIDER_GITHUB
? await getGitHubMetaWithFallback({
owner,
repo,
branch,
commit,
})
: undefined;
return ( return (
<AdminAppInsightsClient <AdminAppInsightsClient
codeMeta={codeMeta}
recommendations={{ recommendations={{
fork: true, fork: true,
forkBehind: true, forkBehind: true,
ai: isAiTextGenerationEnabled, ai: isAiTextGenerationEnabled,
aiRateLimiting: isAiTextGenerationEnabled && !hasVercelBlobStorage, aiRateLimiting: isAiTextGenerationEnabled && !hasVercelBlobStorage,
photoMatting: true,
gridFirst: true,
}} }}
photoStats={{ photoStats={{
photosCount: count, photosCount: count,
photosCountHidden: countHidden,
tagsCount: tags.length, tagsCount: tags.length,
camerasCount: cameras.length, camerasCount: cameras.length,
filmSimulationsCount: filmSimulations.length, filmSimulationsCount: filmSimulations.length,

View File

@ -1,31 +1,47 @@
'use client'; 'use client';
import IconGrSync from '@/app-core/IconGrSync'; import IconGrSync from '@/app-core/IconGrSync';
import Note from '@/components/Note';
import ScoreCard from '@/components/ScoreCard'; import ScoreCard from '@/components/ScoreCard';
import ScoreCardRow from '@/components/ScoreCardRow'; import ScoreCardRow from '@/components/ScoreCardRow';
import WarningNote from '@/components/WarningNote';
import { dateRangeForPhotos, PhotoDateRange } from '@/photo'; import { dateRangeForPhotos, PhotoDateRange } from '@/photo';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { HiSparkles } from 'react-icons/hi'; import { FaCamera } from 'react-icons/fa';
import { FaTag } from 'react-icons/fa';
import { FaRegCalendar } from 'react-icons/fa6';
import {
HiOutlinePhotograph,
HiOutlineRefresh,
HiSparkles,
} from 'react-icons/hi';
import { MdLightbulbOutline } from 'react-icons/md'; import { MdLightbulbOutline } from 'react-icons/md';
import { PiWarningBold } from 'react-icons/pi'; import { PiWarningBold } from 'react-icons/pi';
import { TbCone } from 'react-icons/tb';
import { getGitHubMetaWithFallback } from './github';
import { BiGitBranch, BiGitCommit, BiLogoGithub } from 'react-icons/bi';
import {
TEMPLATE_REPO_BRANCH,
TEMPLATE_REPO_OWNER,
TEMPLATE_REPO_NAME,
VERCEL_GIT_COMMIT_SHA_SHORT,
} from '@/app-core/config';
const DEBUG_COMMIT_SHA = '4cd29ed';
const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes';
type Recommendation = type Recommendation =
'fork' | 'fork' |
'forkBehind' | 'forkBehind' |
'ai' | 'ai' |
'aiRateLimiting'; 'aiRateLimiting' |
'photoMatting' |
'gridFirst';
export default function AdminAppInsightsClient({ export default function AdminAppInsightsClient({
recommendations: { codeMeta,
fork,
forkBehind,
ai,
aiRateLimiting,
},
photoStats: { photoStats: {
photosCount, photosCount,
photosCountHidden,
tagsCount, tagsCount,
camerasCount, camerasCount,
filmSimulationsCount, filmSimulationsCount,
@ -34,23 +50,25 @@ export default function AdminAppInsightsClient({
}, },
debug, debug,
}: { }: {
recommendations: Record<Recommendation, boolean>, codeMeta?: Awaited<ReturnType<typeof getGitHubMetaWithFallback>>
recommendations: Record<Recommendation, boolean>
photoStats: { photoStats: {
photosCount: number photosCount: number
photosCountHidden: number
tagsCount: number tagsCount: number
camerasCount: number camerasCount: number
filmSimulationsCount: number filmSimulationsCount: number
lensesCount: number lensesCount: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}, },
debug?: boolean, debug?: boolean
}) { }) {
const { descriptionWithSpaces } = dateRangeForPhotos(undefined, dateRange); const { descriptionWithSpaces } = dateRangeForPhotos(undefined, dateRange);
const renderTitle = (title: string) => const renderTitle = (title: string) =>
<div className={clsx( <div className={clsx(
'text-center uppercase font-bold tracking-wide', 'uppercase font-medium tracking-wider text-[0.8rem]',
'text-medium', 'text-medium',
)}> )}>
{title} {title}
@ -64,19 +82,53 @@ export default function AdminAppInsightsClient({
'w-full sm:w-[80%] lg:w-[60%]', 'w-full sm:w-[80%] lg:w-[60%]',
'space-y-4 md:space-y-6', 'space-y-4 md:space-y-6',
)}> )}>
{(codeMeta?.isBaseRepo || codeMeta?.isForkedFromBase || debug) && <>
{renderTitle('Build details')}
<ScoreCard>
<ScoreCardRow
icon={<BiLogoGithub size={17} />}
content={<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<div>{codeMeta?.owner ?? TEMPLATE_REPO_OWNER}</div>
<div>/</div>
<div>{codeMeta?.repo ?? TEMPLATE_REPO_NAME}</div>
</div>
<div className="flex items-center gap-1 min-w-0">
<div><BiGitBranch size={17} /></div>
<div className="truncate">
{codeMeta?.branch ?? TEMPLATE_REPO_BRANCH}
</div>
</div>
</div>}
/>
{(codeMeta?.behindBy || debug) &&
<ScoreCardRow
icon={<HiOutlineRefresh
size={17}
className="translate-x-[0.5px] text-amber-600"
/>}
// eslint-disable-next-line max-len
content={`This fork is ${codeMeta?.behindBy ?? 9} commits behind`}
additionalContent={<>
Sync your fork to receive new features and fixes
</>}
/>}
<ScoreCardRow
// icon={<BiLogoGithub size={17} />}
icon={<BiGitCommit size={18} className="translate-y-[0px]" />}
content={<div className="flex items-center gap-2">
<div className="text-medium">
{VERCEL_GIT_COMMIT_SHA_SHORT ?? DEBUG_COMMIT_SHA}
</div>
<div className="truncate">
{codeMeta?.commit ?? DEBUG_COMMIT_MESSAGE}
</div>
</div>}
/>
</ScoreCard>
</>}
{renderTitle('Template recommendations')}
<ScoreCard> <ScoreCard>
<ScoreCardRow
icon={
<PiWarningBold
size={17}
className="translate-x-[0.5px] text-amber-600"
/>
}
content="This fork is 9 commits behind"
additionalContent={<>
Sync your fork to receive new features and fixes
</>}
/>
<ScoreCardRow <ScoreCardRow
icon={<PiWarningBold icon={<PiWarningBold
size={17} size={17}
@ -105,42 +157,55 @@ export default function AdminAppInsightsClient({
content="Enable AI text generation in the app configuration" content="Enable AI text generation in the app configuration"
/> />
</ScoreCard> </ScoreCard>
{renderTitle('Code Observability')}
{(fork || debug) &&
<Note icon={<IconGrSync />}>
Consider forking this repository to receive new features and fixes
</Note>}
{(forkBehind || debug) &&
<WarningNote>
This fork is 9 commits behind
</WarningNote>}
{renderTitle('Template Recommendations')}
{(ai || debug) && <Note icon={<HiSparkles />}>
Enable AI text generation in the app configuration
</Note>}
{(aiRateLimiting || debug) && <WarningNote>
Consider enabling rate limiting to mitigate AI abuse
</WarningNote>}
{renderTitle('Library Stats')} {renderTitle('Library Stats')}
<div className={clsx( <ScoreCard className="uppercase">
'grid grid-cols-2 gap-3 w-full', <ScoreCardRow
'border border-main rounded-md p-6 bg-main shadow-xs', icon={<HiOutlinePhotograph
'uppercase', size={17}
)}> className="translate-y-[0.5px]"
<div className="tracking-wide">Photos</div> />}
<div className="eright">{photosCount}</div> content={<>
<div className="tracking-wide">Tags</div> {photosCount} photos
<div className="text-right">{tagsCount}</div> {photosCountHidden > 0 &&
<div className="tracking-wide">Cameras</div> ` (${photosCountHidden} hidden)`}
<div className="text-right">{camerasCount}</div> </>}
<div className="tracking-wide">Films</div> />
<div className="text-right">{filmSimulationsCount}</div> <ScoreCardRow
<div className="tracking-wide">Lenses</div> icon={<FaTag
<div className="text-right">{lensesCount}</div> size={12}
<span className="text-center col-span-2"> className="translate-y-[3px]"
{descriptionWithSpaces} />}
</span> content={`${tagsCount} tags`}
</div> />
<ScoreCardRow
icon={<FaCamera
size={13}
className="translate-y-[2px]"
/>}
content={`${camerasCount} cameras`}
/>
{filmSimulationsCount &&
<ScoreCardRow
icon={<span className="inline-flex w-3">
<PhotoFilmSimulationIcon
className="shrink-0 translate-x-[-1px] translate-y-[-0.5px]"
height={18}
/>
</span>}
content={`${filmSimulationsCount} film simulations`}
/>}
<ScoreCardRow
icon={<TbCone className="rotate-[270deg] translate-x-[-2px]" />}
content={`${lensesCount} lenses`}
/>
<ScoreCardRow
icon={<FaRegCalendar
size={13}
className="translate-y-[1.5px] translate-x-[-2px]"
/>}
content={descriptionWithSpaces}
/>
</ScoreCard>
</div> </div>
</div> </div>
); );

View File

@ -1,7 +1,7 @@
import { import {
TEMPLATE_BASE_OWNER, TEMPLATE_REPO_OWNER,
TEMPLATE_BASE_REPO, TEMPLATE_REPO_NAME,
TEMPLATE_BASE_BRANCH, TEMPLATE_REPO_BRANCH,
} from '@/app-core/config'; } from '@/app-core/config';
const DEFAULT_BRANCH = 'main'; const DEFAULT_BRANCH = 'main';
@ -23,8 +23,8 @@ interface RepoParams {
// Website urls // Website urls
export const getGitHubRepoUrl = ({ export const getGitHubRepoUrl = ({
owner = TEMPLATE_BASE_OWNER, owner = TEMPLATE_REPO_OWNER,
repo = TEMPLATE_BASE_REPO, repo = TEMPLATE_REPO_NAME,
}: RepoParams = {}) => }: RepoParams = {}) =>
`https://github.com/${owner}/${repo}`; `https://github.com/${owner}/${repo}`;
@ -34,13 +34,13 @@ export const getGitHubCompareUrl = ({
branch = DEFAULT_BRANCH, branch = DEFAULT_BRANCH,
}: RepoParams = {}) => }: RepoParams = {}) =>
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
`${getGitHubRepoUrl({ owner, repo })}/compare/${branch}...${TEMPLATE_BASE_OWNER}:${TEMPLATE_BASE_REPO}:${TEMPLATE_BASE_BRANCH}`; `${getGitHubRepoUrl({ owner, repo })}/compare/${branch}...${TEMPLATE_REPO_OWNER}:${TEMPLATE_REPO_NAME}:${TEMPLATE_REPO_BRANCH}`;
// API urls // API urls
const getGitHubApiRepoUrl = ({ const getGitHubApiRepoUrl = ({
owner = TEMPLATE_BASE_OWNER, owner = TEMPLATE_REPO_OWNER,
repo = TEMPLATE_BASE_REPO, repo = TEMPLATE_REPO_NAME,
}: RepoParams = {}) => }: RepoParams = {}) =>
`https://api.github.com/repos/${owner}/${repo}`; `https://api.github.com/repos/${owner}/${repo}`;
@ -56,10 +56,10 @@ const getGitHubApiCompareToRepoUrl = ({
branch = DEFAULT_BRANCH, branch = DEFAULT_BRANCH,
}: RepoParams = {}) => }: RepoParams = {}) =>
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
`${getGitHubApiRepoUrl()}/compare/${TEMPLATE_BASE_BRANCH}...${owner}:${repo}:${branch}`; `${getGitHubApiRepoUrl()}/compare/${TEMPLATE_REPO_BRANCH}...${owner}:${repo}:${branch}`;
const getGitHubApiCompareToCommitUrl = ({ commit }: RepoParams = {}) => const getGitHubApiCompareToCommitUrl = ({ commit }: RepoParams = {}) =>
`${getGitHubApiRepoUrl()}/compare/${TEMPLATE_BASE_BRANCH}...${commit}`; `${getGitHubApiRepoUrl()}/compare/${TEMPLATE_REPO_BRANCH}...${commit}`;
// Requests // Requests
@ -74,7 +74,7 @@ const getIsRepoForkedFromBase = async (params: RepoParams) => {
const data = await response.json(); const data = await response.json();
return ( return (
Boolean(data.fork) && Boolean(data.fork) &&
data.source?.full_name === `${TEMPLATE_BASE_OWNER}/${TEMPLATE_BASE_REPO}` data.source?.full_name === `${TEMPLATE_REPO_OWNER}/${TEMPLATE_REPO_NAME}`
); );
}; };
@ -97,8 +97,8 @@ const getGitHubCommitsBehindFromCommit = async (params?: RepoParams) => {
}; };
const isRepoBaseRepo = ({ owner, repo }: RepoParams) => const isRepoBaseRepo = ({ owner, repo }: RepoParams) =>
owner?.toLowerCase() === TEMPLATE_BASE_OWNER && owner?.toLowerCase() === TEMPLATE_REPO_OWNER &&
repo?.toLowerCase() === TEMPLATE_BASE_REPO; repo?.toLowerCase() === TEMPLATE_REPO_NAME;
export const getGitHubPublicFork = async ( export const getGitHubPublicFork = async (
params?: RepoParams, params?: RepoParams,
@ -144,6 +144,7 @@ const getGitHubMeta = async (params: RepoParams) => {
: 'This fork is up to date.'; : 'This fork is up to date.';
return { return {
...params,
url, url,
isForkedFromBase, isForkedFromBase,
isBaseRepo, isBaseRepo,
@ -160,6 +161,7 @@ export const getGitHubMetaWithFallback = (params: RepoParams) =>
.catch(e => { .catch(e => {
console.error('Error retrieving GitHub meta', { params, error: e }); console.error('Error retrieving GitHub meta', { params, error: e });
return { return {
...params,
url: undefined, url: undefined,
isForkedFromBase: false, isForkedFromBase: false,
isBaseRepo: undefined, isBaseRepo: undefined,

View File

@ -6,17 +6,21 @@ import type { StorageType } from '@/services/storage';
import { makeUrlAbsolute, shortenUrl } from '@/utility/url'; import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
// HARD-CODED GLOBAL CONFIGURATION // HARD-CODED GLOBAL CONFIGURATION
export const SHOULD_PREFETCH_ALL_LINKS: boolean | undefined = undefined; export const SHOULD_PREFETCH_ALL_LINKS: boolean | undefined = undefined;
// META / SOURCE / DOMAINS // TEMPLATE META
export const SITE_TITLE =
process.env.NEXT_PUBLIC_SITE_TITLE ||
'Photo Blog';
// SOURCE export const TEMPLATE_TITLE = 'Photo Blog';
export const TEMPLATE_BASE_OWNER = 'sambecker'; export const TEMPLATE_DESCRIPTION = 'Store photos with original camera data';
export const TEMPLATE_BASE_REPO = 'exif-photo-blog';
export const TEMPLATE_BASE_BRANCH = 'main'; // SOURCE CODE
export const TEMPLATE_REPO_OWNER = 'sambecker';
export const TEMPLATE_REPO_NAME = 'exif-photo-blog';
export const TEMPLATE_REPO_BRANCH = 'main';
// eslint-disable-next-line max-len
export const TEMPLATE_REPO_URL = `https://github.com/${TEMPLATE_REPO_OWNER}/${TEMPLATE_REPO_NAME}`;
export const VERCEL_GIT_PROVIDER = export const VERCEL_GIT_PROVIDER =
process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER; process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER;
@ -59,6 +63,12 @@ export const IS_PREVIEW = VERCEL_ENV === 'preview';
export const VERCEL_BYPASS_KEY = 'x-vercel-protection-bypass'; export const VERCEL_BYPASS_KEY = 'x-vercel-protection-bypass';
export const VERCEL_BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; export const VERCEL_BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
// SITE META
export const SITE_TITLE =
process.env.NEXT_PUBLIC_SITE_TITLE ||
TEMPLATE_TITLE;
// User-facing domain, potential site title // User-facing domain, potential site title
const SITE_DOMAIN = const SITE_DOMAIN =
process.env.NEXT_PUBLIC_SITE_DOMAIN || process.env.NEXT_PUBLIC_SITE_DOMAIN ||
@ -90,11 +100,14 @@ export const SITE_ABOUT = process.env.NEXT_PUBLIC_SITE_ABOUT;
export const HAS_DEFINED_SITE_DESCRIPTION = export const HAS_DEFINED_SITE_DESCRIPTION =
Boolean(process.env.NEXT_PUBLIC_SITE_DESCRIPTION); Boolean(process.env.NEXT_PUBLIC_SITE_DESCRIPTION);
// STORAGE
// STORAGE: DATABASE // STORAGE: DATABASE
export const HAS_DATABASE = export const HAS_DATABASE =
Boolean(process.env.POSTGRES_URL); Boolean(process.env.POSTGRES_URL);
export const POSTGRES_SSL_ENABLED = export const POSTGRES_SSL_ENABLED =
process.env.DISABLE_POSTGRES_SSL === '1' ? false : true; process.env.DISABLE_POSTGRES_SSL === '1' ? false : true;
// STORAGE: VERCEL KV // STORAGE: VERCEL KV
export const HAS_VERCEL_KV = export const HAS_VERCEL_KV =
Boolean(process.env.KV_URL); Boolean(process.env.KV_URL);

View File

@ -1,24 +1,25 @@
/* eslint-disable max-len */ /* eslint-disable max-len */
import {
TEMPLATE_REPO_OWNER,
TEMPLATE_REPO_NAME,
TEMPLATE_DESCRIPTION,
TEMPLATE_TITLE,
} from '@/app-core/config';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
const REQUIRE_ENV_VARS = false; const REQUIRE_ENV_VARS = false;
const TITLE = 'Photo Blog';
const DESCRIPTION = 'Store photos with original camera data';
const REPO_TEAM = 'sambecker';
const REPO_NAME = 'exif-photo-blog';
export function GET() { export function GET() {
const url = new URL('https://vercel.com/new/clone'); const url = new URL('https://vercel.com/new/clone');
url.searchParams.set('demo-title', TITLE); url.searchParams.set('demo-title', TEMPLATE_TITLE);
url.searchParams.set('demo-description', DESCRIPTION); url.searchParams.set('demo-description', TEMPLATE_DESCRIPTION);
url.searchParams.set('demo-url', 'https://photos.sambecker.com'); url.searchParams.set('demo-url', 'https://photos.sambecker.com');
url.searchParams.set('demo-description', DESCRIPTION); url.searchParams.set('demo-description', TEMPLATE_DESCRIPTION);
url.searchParams.set('demo-image', 'https://photos.sambecker.com/template-image-tight'); url.searchParams.set('demo-image', 'https://photos.sambecker.com/template-image-tight');
url.searchParams.set('project-name', TITLE); url.searchParams.set('project-name', TEMPLATE_TITLE);
url.searchParams.set('repository-name', REPO_NAME); url.searchParams.set('repository-name', TEMPLATE_REPO_NAME);
url.searchParams.set('repository-url', `https://github.com/${REPO_TEAM}/${REPO_NAME}`); url.searchParams.set('repository-url', `https://github.com/${TEMPLATE_REPO_OWNER}/${TEMPLATE_REPO_NAME}`);
url.searchParams.set('from', 'templates'); url.searchParams.set('from', 'templates');
url.searchParams.set('skippable-integrations', '1'); url.searchParams.set('skippable-integrations', '1');
if (REQUIRE_ENV_VARS) { if (REQUIRE_ENV_VARS) {

View File

@ -1,3 +1,4 @@
import { TEMPLATE_REPO_NAME, TEMPLATE_REPO_URL } from '@/app-core/config';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import Link from 'next/link'; import Link from 'next/link';
import { BiLogoGithub } from 'react-icons/bi'; import { BiLogoGithub } from 'react-icons/bi';
@ -9,7 +10,7 @@ export default function RepoLink() {
Made with Made with
</span> </span>
<Link <Link
href="http://github.com/sambecker/exif-photo-blog" href={TEMPLATE_REPO_URL}
target="_blank" target="_blank"
className={clsx( className={clsx(
'flex items-center gap-0.5', 'flex items-center gap-0.5',
@ -21,7 +22,7 @@ export default function RepoLink() {
size={16} size={16}
className="translate-y-[0.5px] hidden xs:inline-block" className="translate-y-[0.5px] hidden xs:inline-block"
/> />
exif-photo-blog {TEMPLATE_REPO_NAME}
</Link> </Link>
</span> </span>
); );

View File

@ -1,12 +1,18 @@
import clsx from 'clsx/lite';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
export default function ScoreCard({ export default function ScoreCard({
children, children,
className,
}: { }: {
children: ReactNode, children: ReactNode,
className?: string,
}) { }) {
return ( return (
<div className="component-surface shadow-xs divide-y divide-main"> <div className={clsx(
'component-surface shadow-xs divide-y divide-main',
className,
)}>
{children} {children}
</div> </div>
); );

View File

@ -1,6 +1,7 @@
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { ReactNode, useState } from 'react'; import { ReactNode, useState } from 'react';
import { FaMinus, FaPlus } from 'react-icons/fa6'; import { FaMinus, FaPlus } from 'react-icons/fa6';
export default function ScoreCardRow({ export default function ScoreCardRow({
icon, icon,
content, content,
@ -11,16 +12,20 @@ export default function ScoreCardRow({
additionalContent?: ReactNode additionalContent?: ReactNode
}) { }) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
return ( return (
<div className={clsx( <div className={clsx(
'flex gap-4', 'flex',
'px-4 py-2', 'py-2 pr-2',
)}> )}>
<div className="pt-[8px] shrink-0 text-main"> <div className={clsx(
'flex justify-center pt-[8px] w-11 sm:w-14',
'shrink-0 text-icon',
)}>
{icon} {icon}
</div> </div>
<div className="grow space-y-2 py-1.5 w-full overflow-auto"> <div className="grow space-y-2 py-1.5 w-full overflow-auto">
<div className="text-main"> <div className="text-main pr-2">
{content} {content}
</div> </div>
{isExpanded && {isExpanded &&
@ -31,7 +36,7 @@ export default function ScoreCardRow({
{additionalContent && <button {additionalContent && <button
type="button" type="button"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className="px-[9px] self-start -mr-1" className="px-[9px] self-start"
> >
{isExpanded {isExpanded
? <FaMinus /> ? <FaMinus />