Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,27 @@ import {
getDefaultIntervalByRange,
timeWindows,
} from '@openpanel/constants';
import type { IServiceDashboard, getReportsByDashboardId } from '@openpanel/db';
import type {
IServiceDashboard,
ShareDashboard,
getReportsByDashboardId,
} from '@openpanel/db';

import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { DashboardShare } from '@/components/dashboard/dashboard-share';

interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
dashboard: IServiceDashboard;
shareDashboard: ShareDashboard | null;
}

export function ListReports({ reports, dashboard }: ListReportsProps) {
export function ListReports({
reports,
dashboard,
shareDashboard,
}: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const { range, startDate, endDate, interval } = useOverviewOptions();
Expand All @@ -58,6 +68,7 @@ export function ListReports({ reports, dashboard }: ListReportsProps) {
<div className="flex items-center justify-end gap-2">
<OverviewRange />
<OverviewInterval />
<DashboardShare data={shareDashboard} />
<Button
icon={PlusIcon}
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Padding } from '@/components/ui/padding';
import { notFound } from 'next/navigation';

import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
import {
getDashboardById,
getReportsByDashboardId,
getShareByDashboardId,
} from '@openpanel/db';

import { ListReports } from './list-reports';

Expand All @@ -15,9 +19,10 @@ interface PageProps {
export default async function Page({
params: { projectId, dashboardId },
}: PageProps) {
const [dashboard, reports] = await Promise.all([
const [dashboard, reports, shareDashboard] = await Promise.all([
getDashboardById(dashboardId, projectId),
getReportsByDashboardId(dashboardId),
getShareByDashboardId(dashboardId),
]);

if (!dashboard) {
Expand All @@ -26,7 +31,11 @@ export default async function Page({

return (
<Padding>
<ListReports reports={reports} dashboard={dashboard} />
<ListReports
reports={reports}
dashboard={dashboard}
shareDashboard={shareDashboard}
/>
</Padding>
);
}
78 changes: 78 additions & 0 deletions apps/dashboard/src/app/(public)/share/dashboard/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { ReportChart } from '@/components/report-chart';
import {
getOrganizationById,
getReportsByDashboardId,
getShareDashboardById,
} from '@openpanel/db';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';

interface PageProps {
params: {
id: string;
};
searchParams: {
header: string;
};
}

export default async function Page({
params: { id },
searchParams,
}: PageProps) {
const share = await getShareDashboardById(id);
if (!share) {
return notFound();
}
if (!share.public) {
return notFound();
}
const dashboardId = share.dashboardId;
const organization = await getOrganizationById(share.organizationId);

if (share.password) {
const cookie = cookies().get(`shared-dashboard-${share.id}`)?.value;
if (!cookie) {
return <ShareEnterPassword shareId={share.id} type="dashboard" />;
}
}

const reports = await getReportsByDashboardId(dashboardId);

return (
<div>
{searchParams.header !== '0' && (
<div className="flex items-center justify-between border-b border-border bg-background p-4">
<div className="col gap-1">
<span className="text-sm">{organization?.name}</span>
<h1 className="text-xl font-medium">{share.dashboard?.name}</h1>
</div>
<a
href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
className="col gap-1 items-end"
>
<span className="text-xs">POWERED BY</span>
<span className="text-xl font-medium">openpanel.dev</span>
</a>
</div>
)}
<div className="p-4">
{reports.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No reports in this dashboard
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{reports.map((report) => (
<div key={report.id} className="card p-4">
<div className="font-medium mb-4">{report.name}</div>
<ReportChart report={report} />
</div>
))}
</div>
)}
</div>
</div>
);
}
45 changes: 34 additions & 11 deletions apps/dashboard/src/components/auth/share-enter-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,33 @@ import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';

export function ShareEnterPassword({ shareId }: { shareId: string }) {
export function ShareEnterPassword({
shareId,
type = 'overview',
}: {
shareId: string;
type?: 'overview' | 'dashboard';
}) {
const router = useRouter();
const mutation = api.auth.signInShare.useMutation({
onSuccess() {
router.refresh();
},
onError() {
toast.error('Incorrect password');
},
});
const mutation =
type === 'dashboard'
? api.auth.signInShareDashboard.useMutation({
onSuccess() {
router.refresh();
},
onError() {
toast.error('Incorrect password');
},
})
: api.auth.signInShare.useMutation({
onSuccess() {
router.refresh();
},
onError() {
toast.error('Incorrect password');
},
});

const form = useForm<ISignInShare>({
resolver: zodResolver(zSignInShare),
defaultValues: {
Expand All @@ -36,14 +53,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
});
});

const title = type === 'dashboard' ? 'Dashboard is locked' : 'Overview is locked';
const description =
type === 'dashboard'
? 'Please enter correct password to access this dashboard'
: 'Please enter correct password to access this overview';

return (
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-xl font-semibold">{title}</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
{description}
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
Expand Down
74 changes: 74 additions & 0 deletions apps/dashboard/src/components/dashboard/dashboard-share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';

import type { ShareDashboard } from '@openpanel/db';

interface DashboardShareProps {
data: ShareDashboard | null;
}

export function DashboardShare({ data }: DashboardShareProps) {
const router = useRouter();
const mutation = api.share.createDashboard.useMutation({
onSuccess() {
router.refresh();
},
});

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
{data?.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{(!data || data.public === false) && (
<DropdownMenuItem onClick={() => pushModal('ShareDashboardModal')}>
<Globe2Icon size={16} className="mr-2" />
Make public
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem asChild>
<Link
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/dashboard/${data.id}`}
>
<EyeIcon size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem
onClick={() => {
mutation.mutate({
...data,
public: false,
password: null,
});
}}
>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
68 changes: 68 additions & 0 deletions apps/dashboard/src/modals/ShareDashboardModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zShareDashboard } from '@openpanel/validation';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';

type IForm = z.infer<typeof zShareDashboard>;

export default function ShareDashboardModal() {
const params = useAppParams<{ dashboardId: string }>();
const { organizationId, dashboardId } = params;
const router = useRouter();

const { register, handleSubmit } = useForm<IForm>({
resolver: zodResolver(zShareDashboard),
defaultValues: {
public: true,
password: '',
dashboardId,
organizationId,
},
});

const mutation = api.share.createDashboard.useMutation({
onError: handleError,
onSuccess(res) {
router.refresh();
toast('Success', {
description: `Your dashboard is now ${
res.public ? 'public' : 'private'
}`,
});
popModal();
},
});

return (
<ModalContent className="max-w-md">
<ModalHeader
title="Dashboard public availability"
text="You can choose if you want to add a password to make it a bit more private."
/>
<form onSubmit={handleSubmit((values) => mutation.mutate(values))}>
<Input
{...register('password')}
placeholder="Enter your password"
size="large"
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" loading={mutation.isLoading}>
Make it public
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}
3 changes: 3 additions & 0 deletions apps/dashboard/src/modals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ const modals = {
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
loading: Loading,
}),
ShareDashboardModal: dynamic(() => import('./ShareDashboardModal'), {
loading: Loading,
}),
AddReference: dynamic(() => import('./AddReference'), {
loading: Loading,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "shares_dashboards" (
"id" TEXT NOT NULL,
"dashboardId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"public" BOOLEAN NOT NULL DEFAULT false,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- CreateIndex
CREATE UNIQUE INDEX "shares_dashboards_id_key" ON "shares_dashboards"("id");

-- CreateIndex
CREATE UNIQUE INDEX "shares_dashboards_dashboardId_key" ON "shares_dashboards"("dashboardId");

-- AddForeignKey
ALTER TABLE "shares_dashboards" ADD CONSTRAINT "shares_dashboards_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "dashboards"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "shares_dashboards" ADD CONSTRAINT "shares_dashboards_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Loading