Added download functionality.

This commit is contained in:
Mike Cao 2025-07-22 00:24:37 -07:00
parent 0debe89d05
commit 7670ec4136
17 changed files with 216 additions and 5 deletions

View file

@ -1,6 +1,7 @@
import { useContext } from 'react';
import { ReportContext } from './Report';
import styles from './ReportBody.module.css';
import { DownloadButton } from '@/components/input/DownloadButton';
export function ReportBody({ children }) {
const { report } = useContext(ReportContext);
@ -9,7 +10,12 @@ export function ReportBody({ children }) {
return null;
}
return <div className={styles.body}>{children}</div>;
return (
<div className={styles.body}>
<DownloadButton filename={report.name} data={report.data} />
{children}
</div>
);
}
export default ReportBody;

View file

@ -31,5 +31,9 @@ export default function ReportPage({ reportId }: { reportId: string }) {
const ReportComponent = reports[report.type];
if (!ReportComponent) {
return null;
}
return <ReportComponent reportId={reportId} />;
}

View file

@ -1,6 +1,6 @@
import { Dropdown, Item } from 'react-basics';
import classNames from 'classnames';
import { useDateRange, useMessages, useSticky } from '@/components/hooks';
import { useDateRange, useMessages, useNavigation, useSticky } from '@/components/hooks';
import WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
import MetricCard from '@/components/metrics/MetricCard';
import MetricsBar from '@/components/metrics/MetricsBar';
@ -8,6 +8,7 @@ import { formatShortTime, formatLongNumber } from '@/lib/format';
import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats';
import useStore, { setWebsiteDateCompare } from '@/store/websites';
import WebsiteFilterButton from './WebsiteFilterButton';
import { ExportButton } from '@/components/input/ExportButton';
import styles from './WebsiteMetricsBar.module.css';
export function WebsiteMetricsBar({
@ -31,6 +32,9 @@ export function WebsiteMetricsBar({
websiteId,
compareMode && dateCompare,
);
const {
query: { view },
} = useNavigation();
const isAllTime = dateRange.value === 'all';
const { pageviews, visitors, visits, bounces, totaltime } = data || {};
@ -109,7 +113,10 @@ export function WebsiteMetricsBar({
</MetricsBar>
</div>
<div className={styles.actions}>
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
<div>
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
{!view && <ExportButton websiteId={websiteId} />}
</div>
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
{compareMode && (
<div className={styles.vs}>

View file

@ -14,6 +14,7 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
const pathname = usePathname();
const tableProps = {
websiteId,
allowDownload: false,
limit: 10,
};
const isSharePage = pathname.includes('/share/');

View file

@ -0,0 +1,75 @@
import { z } from 'zod';
import JSZip from 'jszip';
import Papa from 'papaparse';
import { getRequestFilters, parseRequest } from '@/lib/request';
import { unauthorized, json } from '@/lib/response';
import { canViewWebsite } from '@/lib/auth';
import { pagingParams } from '@/lib/schema';
import { getEventMetrics, getWebsiteEvents } from '@/queries';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
startAt: z.coerce.number().int(),
endAt: z.coerce.number().int(),
...pagingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { startAt, endAt } = query;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const startDate = new Date(+startAt);
const endDate = new Date(+endAt);
const limit = 10;
const offset = 0;
const filters = {
...(await getRequestFilters(query)),
startDate,
endDate,
};
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
getWebsiteEvents(websiteId, { startDate, endDate }, query),
getEventMetrics(websiteId, 'url', filters, limit, offset),
getEventMetrics(websiteId, 'referrer', filters, limit, offset),
getEventMetrics(websiteId, 'browser', filters, limit, offset),
getEventMetrics(websiteId, 'os', filters, limit, offset),
getEventMetrics(websiteId, 'device', filters, limit, offset),
getEventMetrics(websiteId, 'country', filters, limit, offset),
]);
const zip = new JSZip();
const parse = (data: any) => {
return Papa.unparse(data, {
header: true,
skipEmptyLines: true,
});
};
zip.file('events.csv', parse(events?.data));
zip.file('pages.csv', parse(pages));
zip.file('referrers.csv', parse(referrers));
zip.file('browsers.csv', parse(browsers));
zip.file('os.csv', parse(os));
zip.file('devices.csv', parse(devices));
zip.file('countries.csv', parse(countries));
const content = await zip.generateAsync({ type: 'nodebuffer' });
const base64 = content.toString('base64');
return json({ zip: base64 });
}

1
src/assets/download.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
src/assets/export.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg>

After

Width:  |  Height:  |  Size: 493 B

View file

@ -29,7 +29,7 @@ export function useReport(
data.parameters = {
...defaultParameters?.parameters,
...data.parameters,
dateRange: parseDateRange(dateRange.value),
dateRange: dateRange ? parseDateRange(dateRange?.value) : {},
};
setReport(data);

View file

@ -8,6 +8,8 @@ import Change from '@/assets/change.svg';
import Clock from '@/assets/clock.svg';
import Compare from '@/assets/compare.svg';
import Dashboard from '@/assets/dashboard.svg';
import Download from '@/assets/download.svg';
import Export from '@/assets/export.svg';
import Eye from '@/assets/eye.svg';
import Gear from '@/assets/gear.svg';
import Globe from '@/assets/globe.svg';
@ -37,6 +39,8 @@ const icons = {
Clock,
Compare,
Dashboard,
Download,
Export,
Eye,
Gear,
Globe,

View file

@ -0,0 +1,41 @@
import Papa from 'papaparse';
import { Button, Icon, TooltipPopup } from 'react-basics';
import Icons from '@/components/icons';
import { useMessages } from '@/components/hooks';
export function DownloadButton({
filename = 'data',
data,
}: {
filename?: string;
data?: any;
onClick?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const handleClick = async () => {
downloadCsv(`${filename}.csv`, Papa.unparse(data));
};
return (
<TooltipPopup label={formatMessage(labels.download)} position="top">
<Button variant="quiet" onClick={handleClick} disabled={!data}>
<Icon>
<Icons.Download />
</Icon>
</Button>
</TooltipPopup>
);
}
function downloadCsv(filename: string, data: any) {
const blob = new Blob([data], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,47 @@
import { useState } from 'react';
import { Icon, TooltipPopup, LoadingButton } from 'react-basics';
import Icons from '@/components/icons';
import { useMessages, useApi } from '@/components/hooks';
import { useFilterParams } from '@/components/hooks/useFilterParams';
import { useSearchParams } from 'next/navigation';
export function ExportButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const [isLoading, setIsLoading] = useState(false);
const params = useFilterParams(websiteId);
const searchParams = useSearchParams();
const { get } = useApi();
const handleClick = async () => {
setIsLoading(true);
const { zip } = await get(`/websites/${websiteId}/export`, { ...params, ...searchParams });
const binary = atob(zip);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'download.zip';
a.click();
URL.revokeObjectURL(url);
setIsLoading(false);
};
return (
<TooltipPopup label={formatMessage(labels.download)} position="top">
<LoadingButton variant="quiet" isLoading={isLoading} onClick={handleClick}>
<Icon>
<Icons.Download />
</Icon>
</LoadingButton>
</TooltipPopup>
);
}

View file

@ -316,6 +316,7 @@ export const labels = defineMessages({
other: { id: 'label.other', defaultMessage: 'Other' },
chart: { id: 'label.chart', defaultMessage: 'Chart' },
table: { id: 'label.table', defaultMessage: 'Table' },
download: { id: 'label.download', defaultMessage: 'Download' },
});
export const messages = defineMessages({

View file

@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
metric={formatMessage(labels.actions)}
onDataLoad={handleDataLoad}
renderLabel={renderLabel}
allowDownload={false}
/>
);
}

View file

@ -14,6 +14,13 @@
margin-bottom: 10px;
}
.buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.footer {
display: flex;
justify-content: center;

View file

@ -15,6 +15,7 @@ import {
import Icons from '@/components/icons';
import ListTable, { ListTableProps } from './ListTable';
import styles from './MetricsTable.module.css';
import { DownloadButton } from '@/components/input/DownloadButton';
export interface MetricsTableProps extends ListTableProps {
websiteId: string;
@ -29,6 +30,7 @@ export interface MetricsTableProps extends ListTableProps {
searchFormattedValues?: boolean;
showMore?: boolean;
params?: { [key: string]: any };
allowDownload?: boolean;
children?: ReactNode;
}
@ -44,6 +46,7 @@ export function MetricsTable({
searchFormattedValues = false,
showMore = true,
params,
allowDownload = true,
children,
...props
}: MetricsTableProps) {
@ -104,7 +107,10 @@ export function MetricsTable({
autoFocus={true}
/>
)}
{children}
<div className={styles.buttons}>
{children}
{allowDownload && <DownloadButton filename={type} data={filteredData} />}
</div>
</div>
{data && !error && (
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} />