Compare commits

..

No commits in common. "1483241494e8d44b7ff7310528a3607288bfeae7" and "65f657dd230bef9004a6afeb17ec7615e5cab2b2" have entirely different histories.

17 changed files with 1186 additions and 1205 deletions

View file

@ -1,7 +1,5 @@
ARG NODE_IMAGE_VERSION="22-alpine"
# Install dependencies only when needed # Install dependencies only when needed
FROM node:${NODE_IMAGE_VERSION} AS deps FROM node:22-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
@ -10,7 +8,7 @@ RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Rebuild the source code only when needed # Rebuild the source code only when needed
FROM node:${NODE_IMAGE_VERSION} AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
@ -27,10 +25,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build-docker RUN npm run build-docker
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM node:${NODE_IMAGE_VERSION} AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
ARG PRISMA_VERSION="6.19.0"
ARG NODE_OPTIONS ARG NODE_OPTIONS
ENV NODE_ENV=production ENV NODE_ENV=production
@ -39,14 +36,13 @@ ENV NODE_OPTIONS=$NODE_OPTIONS
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
RUN npm install -g pnpm
RUN set -x \ RUN set -x \
&& apk add --no-cache curl \ && apk add --no-cache curl
&& npm install -g pnpm
# Script dependencies # Script dependencies
RUN pnpm --allow-build='@prisma/engines' add npm-run-all dotenv chalk semver \ RUN pnpm --allow-build='@prisma/engines' add npm-run-all dotenv chalk semver prisma@6.18.0 @prisma/adapter-pg@6.18.0
prisma@${PRISMA_VERSION} \
@prisma/adapter-pg@${PRISMA_VERSION}
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma

View file

@ -72,7 +72,7 @@
"@prisma/extension-read-replicas": "^0.4.1", "@prisma/extension-read-replicas": "^0.4.1",
"@react-spring/web": "^10.0.3", "@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.5",
"@umami/react-zen": "^0.211.0", "@umami/react-zen": "^0.211.0",
"@umami/redis-client": "^0.29.0", "@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
@ -92,7 +92,7 @@
"esbuild": "^0.25.11", "esbuild": "^0.25.11",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"immer": "^10.2.0", "immer": "^10.2.0",
"ipaddr.js": "^2.3.0", "ipaddr.js": "^2.0.1",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"is-docker": "^3.0.0", "is-docker": "^3.0.0",
"is-localhost-ip": "^2.0.0", "is-localhost-ip": "^2.0.0",
@ -102,15 +102,15 @@
"kafkajs": "^2.1.0", "kafkajs": "^2.1.0",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",
"maxmind": "^5.0.0", "maxmind": "^5.0.0",
"next": "^15.5.7", "next": "15.5.3",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"prisma": "^6.18.0", "prisma": "^6.18.0",
"pure-rand": "^7.0.1", "pure-rand": "^7.0.1",
"react": "^19.2.1", "react": "^19.2.0",
"react-dom": "^19.2.1", "react-dom": "^19.2.0",
"react-error-boundary": "^4.0.4", "react-error-boundary": "^4.0.4",
"react-intl": "^7.1.14", "react-intl": "^7.1.14",
"react-simple-maps": "^2.3.0", "react-simple-maps": "^2.3.0",
@ -122,13 +122,13 @@
"thenby": "^1.3.4", "thenby": "^1.3.4",
"ua-parser-js": "^2.0.6", "ua-parser-js": "^2.0.6",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"zod": "^4.1.13", "zod": "^4.1.12",
"zustand": "^5.0.9" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "^2.3.6",
"@formatjs/cli": "^4.2.29", "@formatjs/cli": "^4.2.29",
"@netlify/plugin-nextjs": "^5.15.1", "@netlify/plugin-nextjs": "^5.14.4",
"@rollup/plugin-alias": "^5.0.0", "@rollup/plugin-alias": "^5.0.0",
"@rollup/plugin-commonjs": "^25.0.4", "@rollup/plugin-commonjs": "^25.0.4",
"@rollup/plugin-json": "^6.0.0", "@rollup/plugin-json": "^6.0.0",
@ -138,7 +138,7 @@
"@rollup/plugin-typescript": "^12.3.0", "@rollup/plugin-typescript": "^12.3.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.9.2", "@types/node": "^24.9.2",
"@types/react": "^19.2.7", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-react-compiler": "19.1.0-rc.2",
@ -156,7 +156,7 @@
"rollup": "^4.52.5", "rollup": "^4.52.5",
"rollup-plugin-copy": "^3.4.0", "rollup-plugin-copy": "^3.4.0",
"rollup-plugin-delete": "^3.0.1", "rollup-plugin-delete": "^3.0.1",
"rollup-plugin-dts": "^6.3.0", "rollup-plugin-dts": "^6.2.3",
"rollup-plugin-node-externals": "^8.1.1", "rollup-plugin-node-externals": "^8.1.1",
"rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2", "rollup-plugin-postcss": "^4.0.2",
@ -165,7 +165,7 @@
"stylelint-config-prettier": "^9.0.3", "stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^14.0.0", "stylelint-config-recommended": "^14.0.0",
"tar": "^6.1.2", "tar": "^6.1.2",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.5",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",

2220
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,6 @@ export function AdminLayout({ children }: { children: ReactNode }) {
border="right" border="right"
backgroundColor backgroundColor
marginRight="2" marginRight="2"
padding="3"
> >
<AdminNav /> <AdminNav />
</Column> </Column>

View file

@ -50,7 +50,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
label={formatMessage(labels.role)} label={formatMessage(labels.role)}
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<Select defaultValue={user.role}> <Select defaultSelectedKey={user.role}>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly"> <ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)} {formatMessage(labels.viewOnly)}
</ListItem> </ListItem>

View file

@ -1,4 +1,4 @@
import { IconLabel } from '@umami/react-zen'; import { Icon, Text } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton'; import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { useLink, useMessages, useSlug } from '@/components/hooks'; import { useLink, useMessages, useSlug } from '@/components/hooks';
@ -11,8 +11,11 @@ export function LinkHeader() {
return ( return (
<PageHeader title={link.name} description={link.url} icon={<Link />}> <PageHeader title={link.name} description={link.url} icon={<Link />}>
<LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor> <LinkButton href={getSlugUrl(link.slug)} target="_blank">
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} /> <Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</LinkButton> </LinkButton>
</PageHeader> </PageHeader>
); );

View file

@ -1,4 +1,4 @@
import { IconLabel } from '@umami/react-zen'; import { Icon, Text } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton'; import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, usePixel, useSlug } from '@/components/hooks'; import { useMessages, usePixel, useSlug } from '@/components/hooks';
@ -11,8 +11,11 @@ export function PixelHeader() {
return ( return (
<PageHeader title={pixel.name} icon={<Grid2x2 />}> <PageHeader title={pixel.name} icon={<Grid2x2 />}>
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor> <LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false}>
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} /> <Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</LinkButton> </LinkButton>
</PageHeader> </PageHeader>
); );

View file

@ -14,7 +14,6 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
border="right" border="right"
backgroundColor backgroundColor
marginRight="2" marginRight="2"
padding="3"
> >
<SettingsNav /> <SettingsNav />
</Column> </Column>

View file

@ -3,7 +3,7 @@ import { SettingsLayout } from './SettingsLayout';
export default function ({ children }) { export default function ({ children }) {
if (process.env.cloudMode) { if (process.env.cloudMode) {
return null; //return null;
} }
return <SettingsLayout>{children}</SettingsLayout>; return <SettingsLayout>{children}</SettingsLayout>;

View file

@ -9,7 +9,6 @@ export interface LinkButtonProps extends ButtonProps {
scroll?: boolean; scroll?: boolean;
variant?: any; variant?: any;
prefetch?: boolean; prefetch?: boolean;
asAnchor?: boolean;
children?: ReactNode; children?: ReactNode;
} }
@ -20,22 +19,15 @@ export function LinkButton({
target, target,
prefetch, prefetch,
children, children,
asAnchor,
...props ...props
}: LinkButtonProps) { }: LinkButtonProps) {
const { dir } = useLocale(); const { dir } = useLocale();
return ( return (
<Button {...props} variant={variant} asChild> <Button {...props} variant={variant} asChild>
{asAnchor ? ( <Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
<a href={href} target={target}> {children}
{children} </Link>
</a>
) : (
<Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
{children}
</Link>
)}
</Button> </Button>
); );
} }

View file

@ -50,9 +50,7 @@ export function PageHeader({
</Text> </Text>
)} )}
</Column> </Column>
<Row justifyContent="flex-end" alignItems="center"> <Row justifyContent="flex-end">{children}</Row>
{children}
</Row>
</Grid> </Grid>
); );
} }

View file

@ -51,7 +51,7 @@ export function SideMenu({
}; };
return ( return (
<Column gap overflowY="auto" justifyContent="space-between" position="sticky" top="20px"> <Column gap overflowY="auto" justifyContent="space-between">
{title && ( {title && (
<Row padding> <Row padding>
<Heading size="1">{title}</Heading> <Heading size="1">{title}</Heading>

View file

@ -1,11 +1,10 @@
import { useToast } from '@umami/react-zen'; import { useToast } from '@umami/react-zen';
import type { ApiError } from '@/lib/types';
import { useApi } from '../useApi'; import { useApi } from '../useApi';
import { useModified } from '../useModified'; import { useModified } from '../useModified';
export function useUpdateQuery(path: string, params?: Record<string, any>) { export function useUpdateQuery(path: string, params?: Record<string, any>) {
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
const query = useMutation<any, ApiError, Record<string, any>>({ const query = useMutation({
mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }), mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }),
}); });
const { touch } = useModified(); const { touch } = useModified();

View file

@ -1,6 +1,5 @@
import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl'; import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl';
import { labels, messages } from '@/components/messages'; import { labels, messages } from '@/components/messages';
import type { ApiError } from '@/lib/types';
type FormatMessage = ( type FormatMessage = (
descriptor: MessageDescriptor, descriptor: MessageDescriptor,
@ -13,7 +12,7 @@ interface UseMessages {
messages: typeof messages; messages: typeof messages;
labels: typeof labels; labels: typeof labels;
getMessage: (id: string) => string; getMessage: (id: string) => string;
getErrorMessage: (error: ApiError) => string | undefined; getErrorMessage: (error: unknown) => string | undefined;
FormattedMessage: typeof FormattedMessage; FormattedMessage: typeof FormattedMessage;
} }
@ -26,7 +25,7 @@ export function useMessages(): UseMessages {
return message ? formatMessage(message) : id; return message ? formatMessage(message) : id;
}; };
const getErrorMessage = (error: ApiError) => { const getErrorMessage = (error: unknown) => {
if (!error) { if (!error) {
return undefined; return undefined;
} }

View file

@ -1,6 +1,6 @@
import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen'; import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
import { isAfter } from 'date-fns'; import { isAfter } from 'date-fns';
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks'; import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks';
import { ChevronRight } from '@/components/icons'; import { ChevronRight } from '@/components/icons';
import { getDateRangeValue } from '@/lib/date'; import { getDateRangeValue } from '@/lib/date';
@ -45,9 +45,13 @@ export function WebsiteDateFilter({
} }
}; };
const handleIncrement = increment => { const handleIncrement = useCallback(
router.push(updateParams({ offset: Number(offset) + increment })); (increment: number) => {
}; router.push(updateParams({ offset: +offset + increment }));
},
[offset],
);
const handleSelect = (compare: any) => { const handleSelect = (compare: any) => {
router.push(updateParams({ compare })); router.push(updateParams({ compare }));
}; };

View file

@ -206,10 +206,6 @@ async function rawQuery(sql: string, data: Record<string, any>, name?: string):
return `$${params.length}${type ?? ''}`; return `$${params.length}${type ?? ''}`;
}); });
if (process.env.DATABASE_REPLICA_URL && '$replica' in client) {
return client.$replica().$queryRawUnsafe(query, ...params);
}
return client.$queryRawUnsafe(query, ...params); return client.$queryRawUnsafe(query, ...params);
} }
@ -300,6 +296,10 @@ function getSchema() {
} }
function getClient() { function getClient() {
if (!process.env.DATABASE_URL) {
return null;
}
const url = process.env.DATABASE_URL; const url = process.env.DATABASE_URL;
const replicaUrl = process.env.DATABASE_REPLICA_URL; const replicaUrl = process.env.DATABASE_REPLICA_URL;
const logQuery = process.env.LOG_QUERY; const logQuery = process.env.LOG_QUERY;
@ -307,49 +307,43 @@ function getClient() {
const connectionUrl = new URL(url); const connectionUrl = new URL(url);
const schema = connectionUrl.searchParams.get('schema') ?? undefined; const schema = connectionUrl.searchParams.get('schema') ?? undefined;
const baseAdapter = new PrismaPg({ connectionString: url }, { schema }); const adapter = new PrismaPg({ connectionString: url.toString() }, { schema });
const baseClient = new PrismaClient({ const prisma = new PrismaClient({
adapter: baseAdapter, adapter,
errorFormat: 'pretty', errorFormat: 'pretty',
...(logQuery ? PRISMA_LOG_OPTIONS : {}), ...(logQuery ? PRISMA_LOG_OPTIONS : {}),
}); });
if (logQuery) { if (replicaUrl) {
baseClient.$on('query', log); const replicaAdapter = new PrismaPg({ connectionString: replicaUrl.toString() }, { schema });
const replicaClient = new PrismaClient({
adapter: replicaAdapter,
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
});
prisma.$extends(
readReplicas({
replicas: [replicaClient],
}),
);
} }
if (!replicaUrl) {
log('Prisma initialized');
globalThis[PRISMA] ??= baseClient;
return baseClient;
}
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema });
const replicaClient = new PrismaClient({
adapter: replicaAdapter,
errorFormat: 'pretty',
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
});
if (logQuery) { if (logQuery) {
replicaClient.$on('query', log); prisma.$on('query' as never, log);
} }
const extended = baseClient.$extends( log('Prisma initialized');
readReplicas({
replicas: [replicaClient],
}),
);
log('Prisma initialized (with replica)'); if (!globalThis[PRISMA]) {
globalThis[PRISMA] ??= extended; globalThis[PRISMA] = prisma;
}
return extended; return prisma;
} }
const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>; const client: PrismaClient = globalThis[PRISMA] || getClient();
export default { export default {
client, client,

View file

@ -136,8 +136,3 @@ export interface RealtimeData {
urls: Record<string, number>; urls: Record<string, number>;
visitors: any[]; visitors: any[];
} }
export interface ApiError extends Error {
code?: string;
message: string;
}