Add board share management UI
This commit is contained in:
@@ -202,7 +202,7 @@
|
||||
"number-of-records": "{x} {x, plural, one {record} other {records}}",
|
||||
"ok": "OK",
|
||||
"online": "Online",
|
||||
"open": "Open",
|
||||
"open": "Mixed",
|
||||
"organic-search": "Organic search",
|
||||
"organic-shopping": "Organic shopping",
|
||||
"organic-social": "Organic social",
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface BoardContextValue {
|
||||
export const BoardContext = createContext<BoardContextValue>(null);
|
||||
|
||||
const createDefaultBoard = (): Partial<Board> => ({
|
||||
type: BOARD_TYPES.open,
|
||||
type: BOARD_TYPES.mixed,
|
||||
name: '',
|
||||
description: '',
|
||||
parameters: {
|
||||
@@ -93,7 +93,7 @@ export function BoardProvider({
|
||||
}
|
||||
return post('/boards', {
|
||||
...boardData,
|
||||
type: boardData.type || BOARD_TYPES.open,
|
||||
type: boardData.type || BOARD_TYPES.mixed,
|
||||
slug: '',
|
||||
teamId,
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@ export function BoardEditForm() {
|
||||
>
|
||||
<Box width="100%" maxWidth="360px">
|
||||
<Select value={boardType} onChange={handleTypeChange} width="100%">
|
||||
<ListItem id={BOARD_TYPES.open}>{t(labels.open)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Share } from '@/components/icons';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { BoardShareDialog } from './BoardShareDialog';
|
||||
|
||||
export function BoardShareButton({ boardId }: { boardId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogButton
|
||||
icon={<Share />}
|
||||
label={t(labels.share)}
|
||||
title={null}
|
||||
width="900px"
|
||||
>
|
||||
<BoardShareDialog boardId={boardId} />
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button, Column, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
||||
|
||||
export function BoardShareCreateForm({
|
||||
boardId,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
boardId: string;
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const { post } = useApi();
|
||||
const { touch } = useModified();
|
||||
const { t, labels, getErrorMessage } = useMessages();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
|
||||
const handleSubmit = async (data: { name: string }) => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await post(`/boards/${boardId}/shares`, {
|
||||
name: data.name,
|
||||
});
|
||||
|
||||
touch('shares');
|
||||
onSave?.();
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ name: '' }}>
|
||||
<Column gap="4">
|
||||
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
|
||||
<TextField autoComplete="off" autoFocus />
|
||||
</FormField>
|
||||
<Row justifyContent="flex-end" gap="3">
|
||||
{onCancel && (
|
||||
<Button isDisabled={isPending} onPress={onCancel}>
|
||||
{t(labels.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
<FormSubmitButton variant="primary" isDisabled={isPending}>
|
||||
{t(labels.add)}
|
||||
</FormSubmitButton>
|
||||
</Row>
|
||||
</Column>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button, Column, Heading, Row, Text } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { useBoardSharesQuery, useMessages } from '@/components/hooks';
|
||||
import { BoardShareCreateForm } from './BoardShareCreateForm';
|
||||
import { BoardSharesTable } from './BoardSharesTable';
|
||||
|
||||
export function BoardShareDialog({ boardId }: { boardId: string }) {
|
||||
const { data, error, isLoading } = useBoardSharesQuery({ boardId });
|
||||
const shares = data?.data || [];
|
||||
const hasShares = shares.length > 0;
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<BoardShareDialogContent boardId={boardId} hasShares={hasShares} shares={shares} />
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardShareDialogContent({
|
||||
boardId,
|
||||
hasShares,
|
||||
shares,
|
||||
}: {
|
||||
boardId: string;
|
||||
hasShares: boolean;
|
||||
shares: any[];
|
||||
}) {
|
||||
const { t, labels, messages } = useMessages();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const showCreateForm = !hasShares || isCreating;
|
||||
|
||||
return (
|
||||
<Column gap="4">
|
||||
<Row justifyContent="space-between" alignItems="center">
|
||||
<Heading>{t(labels.share)}</Heading>
|
||||
{hasShares && !isCreating && (
|
||||
<Button variant="primary" onPress={() => setIsCreating(true)}>
|
||||
<IconLabel icon={<Plus size={16} />} label={t(labels.add)} />
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
{showCreateForm && (
|
||||
<BoardShareCreateForm
|
||||
boardId={boardId}
|
||||
onSave={() => setIsCreating(false)}
|
||||
onCancel={hasShares ? () => setIsCreating(false) : undefined}
|
||||
/>
|
||||
)}
|
||||
{hasShares ? (
|
||||
<>
|
||||
<Text>{t(messages.shareUrl)}</Text>
|
||||
<BoardSharesTable data={shares} />
|
||||
</>
|
||||
) : (
|
||||
!showCreateForm && <Text color="muted">{t(messages.noDataAvailable)}</Text>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
import { useConfig, useMessages, useMobile } from '@/components/hooks';
|
||||
import { ShareDeleteButton } from '@/app/(main)/websites/[websiteId]/settings/ShareDeleteButton';
|
||||
|
||||
export function BoardSharesTable(props: DataTableProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { cloudMode } = useConfig();
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
const getUrl = (slug: string) => {
|
||||
if (cloudMode) {
|
||||
return `${process.env.cloudUrl}/share/${slug}`;
|
||||
}
|
||||
|
||||
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable {...props} displayMode={isMobile ? 'cards' : 'table'}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
{({ name }: any) => name}
|
||||
</DataColumn>
|
||||
<DataColumn id="slug" label={t(labels.shareUrl)} width="2fr">
|
||||
{({ slug }: any) => {
|
||||
const url = getUrl(slug);
|
||||
|
||||
return (
|
||||
<Row alignItems="center" gap="1" overflow="hidden">
|
||||
<ExternalLink href={url} prefetch={false}>
|
||||
{isMobile ? slug : url}
|
||||
</ExternalLink>
|
||||
<CopyButton value={url} label="Copy URL" />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)}>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="60px">
|
||||
{({ id, slug }: any) => (
|
||||
<Row>
|
||||
<ShareDeleteButton shareId={id} slug={slug} />
|
||||
</Row>
|
||||
)}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import { Column } from '@umami/react-zen';
|
||||
import { useBoard } from '@/components/hooks';
|
||||
import { BoardViewRow } from './BoardViewRow';
|
||||
|
||||
export function BoardViewBody() {
|
||||
export function BoardViewBody({ showEntityBadges = true }: { showEntityBadges?: boolean }) {
|
||||
const { board } = useBoard();
|
||||
const rows = board?.parameters?.rows ?? [];
|
||||
|
||||
return (
|
||||
<Column gap="3">
|
||||
{rows.map(row => (
|
||||
<BoardViewRow key={row.id} columns={row.columns} />
|
||||
<BoardViewRow key={row.id} columns={row.columns} showEntityBadges={showEntityBadges} />
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,18 @@ import { getComponentDefinition } from '../boardComponentRegistry';
|
||||
import { useBoardEntityBadgeProps } from '../useBoardEntityBadgeProps';
|
||||
import { BoardComponentRenderer } from './BoardComponentRenderer';
|
||||
|
||||
export function BoardViewColumn({ component }: { component?: BoardComponentConfig }) {
|
||||
export function BoardViewColumn({
|
||||
component,
|
||||
showEntityBadge = true,
|
||||
}: {
|
||||
component?: BoardComponentConfig;
|
||||
showEntityBadge?: boolean;
|
||||
}) {
|
||||
const { board } = useBoard();
|
||||
const boardType = getBoardType(board);
|
||||
const definition = component ? getComponentDefinition(component.type) : undefined;
|
||||
const { entityType, entityId } = getResolvedComponentEntity(board, component);
|
||||
const entityBadge = useBoardEntityBadgeProps(entityType, entityId);
|
||||
const entityBadge = useBoardEntityBadgeProps(entityType, entityId, showEntityBadge);
|
||||
|
||||
if (!component || (!entityId && definition?.requiresWebsite !== false)) {
|
||||
return null;
|
||||
@@ -24,7 +30,7 @@ export function BoardViewColumn({ component }: { component?: BoardComponentConfi
|
||||
|
||||
return (
|
||||
<Panel title={title} description={description} height="100%" position="relative">
|
||||
{isOpenBoardType(boardType) && entityBadge && (
|
||||
{showEntityBadge && isOpenBoardType(boardType) && entityBadge && (
|
||||
<Box position="absolute" top="12px" right="12px" zIndex={100}>
|
||||
<BoardEntityBadge {...entityBadge} />
|
||||
</Box>
|
||||
|
||||
@@ -7,21 +7,33 @@ import { getBoardEntity } from '@/lib/boards';
|
||||
import { Edit } from '@/components/icons';
|
||||
import { BoardEntityBadge } from '../BoardEntityBadge';
|
||||
import { useBoardEntityBadgeProps } from '../useBoardEntityBadgeProps';
|
||||
import { BoardShareButton } from './BoardShareButton';
|
||||
|
||||
export function BoardViewHeader() {
|
||||
export function BoardViewHeader({
|
||||
showActions = true,
|
||||
showEntityBadge = true,
|
||||
}: {
|
||||
showActions?: boolean;
|
||||
showEntityBadge?: boolean;
|
||||
}) {
|
||||
const { board } = useBoard();
|
||||
const { renderUrl } = useNavigation();
|
||||
const { t, labels } = useMessages();
|
||||
const { entityType, entityId } = getBoardEntity(board);
|
||||
const entityBadge = useBoardEntityBadgeProps(entityType, entityId);
|
||||
const entityBadge = useBoardEntityBadgeProps(entityType, entityId, showEntityBadge);
|
||||
|
||||
return (
|
||||
<PageHeader title={board?.name} description={board?.description}>
|
||||
<Row alignItems="center" gap>
|
||||
{entityBadge && <BoardEntityBadge {...entityBadge} />}
|
||||
<LinkButton href={renderUrl(`/boards/${board?.id}/edit`, false)}>
|
||||
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
|
||||
</LinkButton>
|
||||
{showEntityBadge && entityBadge && <BoardEntityBadge {...entityBadge} />}
|
||||
{showActions && board?.id && (
|
||||
<>
|
||||
<BoardShareButton boardId={board.id} />
|
||||
<LinkButton href={renderUrl(`/boards/${board.id}/edit`, false)}>
|
||||
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</PageHeader>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,24 @@ import { BoardControls } from './BoardControls';
|
||||
import { BoardViewBody } from './BoardViewBody';
|
||||
import { BoardViewHeader } from './BoardViewHeader';
|
||||
|
||||
export function BoardViewPage({ boardId }: { boardId: string }) {
|
||||
export function BoardViewPage({
|
||||
boardId,
|
||||
showActions = true,
|
||||
showControls = true,
|
||||
showEntityBadges = true,
|
||||
}: {
|
||||
boardId: string;
|
||||
showActions?: boolean;
|
||||
showControls?: boolean;
|
||||
showEntityBadges?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BoardProvider boardId={boardId}>
|
||||
<PageBody>
|
||||
<Column>
|
||||
<BoardViewHeader />
|
||||
<BoardControls />
|
||||
<BoardViewBody />
|
||||
<BoardViewHeader showActions={showActions} showEntityBadge={showEntityBadges} />
|
||||
{showControls && <BoardControls />}
|
||||
<BoardViewBody showEntityBadges={showEntityBadges} />
|
||||
</Column>
|
||||
</PageBody>
|
||||
</BoardProvider>
|
||||
|
||||
@@ -3,7 +3,13 @@ import type { BoardColumn } from '@/lib/types';
|
||||
import { BoardViewColumn } from './BoardViewColumn';
|
||||
import { MIN_COLUMN_WIDTH } from './boardConstants';
|
||||
|
||||
export function BoardViewRow({ columns }: { columns: BoardColumn[] }) {
|
||||
export function BoardViewRow({
|
||||
columns,
|
||||
showEntityBadges = true,
|
||||
}: {
|
||||
columns: BoardColumn[];
|
||||
showEntityBadges?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Row gap="3" width="100%" overflowX="auto">
|
||||
{columns.map(column => (
|
||||
@@ -14,7 +20,7 @@ export function BoardViewRow({ columns }: { columns: BoardColumn[] }) {
|
||||
flexBasis="0%"
|
||||
minWidth={`${MIN_COLUMN_WIDTH}px`}
|
||||
>
|
||||
<BoardViewColumn component={column.component} />
|
||||
<BoardViewColumn component={column.component} showEntityBadge={showEntityBadges} />
|
||||
</Box>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useLinkQuery, usePixelQuery, useWebsiteQuery } from '@/components/hooks';
|
||||
import type { BoardEntityType } from '@/lib/boards';
|
||||
|
||||
export function useBoardEntityBadgeProps(entityType?: BoardEntityType, entityId?: string) {
|
||||
const { data: website } = useWebsiteQuery(entityType === 'website' ? entityId : undefined);
|
||||
const { data: pixel } = usePixelQuery(entityType === 'pixel' ? entityId : undefined);
|
||||
const { data: link } = useLinkQuery(entityType === 'link' ? entityId : undefined);
|
||||
export function useBoardEntityBadgeProps(
|
||||
entityType?: BoardEntityType,
|
||||
entityId?: string,
|
||||
enabled = true,
|
||||
) {
|
||||
const { data: website } = useWebsiteQuery(
|
||||
enabled && entityType === 'website' ? entityId : undefined,
|
||||
);
|
||||
const { data: pixel } = usePixelQuery(enabled && entityType === 'pixel' ? entityId : undefined);
|
||||
const { data: link } = useLinkQuery(enabled && entityType === 'link' ? entityId : undefined);
|
||||
|
||||
if (entityType === 'website' && website?.name) {
|
||||
return { type: entityType, name: website.name };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
import { useConfig, useMessages, useMobile } from '@/components/hooks';
|
||||
@@ -26,9 +27,12 @@ export function SharesTable(props: DataTableProps) {
|
||||
{({ slug }: any) => {
|
||||
const url = getUrl(slug);
|
||||
return (
|
||||
<ExternalLink href={url} prefetch={false}>
|
||||
{isMobile ? slug : url}
|
||||
</ExternalLink>
|
||||
<Row alignItems="center" gap="1" overflow="hidden">
|
||||
<ExternalLink href={url} prefetch={false}>
|
||||
{isMobile ? slug : url}
|
||||
</ExternalLink>
|
||||
<CopyButton value={url} label="Copy URL" />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { BOARD_TYPES } from '@/lib/boards';
|
||||
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
|
||||
@@ -28,11 +28,12 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
|
||||
type: z
|
||||
.enum([
|
||||
BOARD_TYPES.dashboard,
|
||||
BOARD_TYPES.open,
|
||||
BOARD_TYPES.mixed,
|
||||
BOARD_TYPES.website,
|
||||
BOARD_TYPES.pixel,
|
||||
BOARD_TYPES.link,
|
||||
])
|
||||
.or(z.literal('open'))
|
||||
.optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -46,7 +47,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
|
||||
}
|
||||
|
||||
const { boardId } = await params;
|
||||
const { type, name, description, parameters } = body;
|
||||
const { name, description, parameters } = body;
|
||||
const type = normalizeBoardType(body.type);
|
||||
|
||||
if (!(await canUpdateBoard(auth, boardId))) {
|
||||
return unauthorized();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from 'zod';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams, pagingParams } from '@/lib/schema';
|
||||
import { canUpdateBoard, canViewBoard } from '@/permissions';
|
||||
import { createShare, getSharesByEntityId } from '@/queries/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ boardId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
...filterParams,
|
||||
...pagingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { boardId } = await params;
|
||||
const { page, pageSize, search } = query;
|
||||
|
||||
if (!(await canViewBoard(auth, boardId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getSharesByEntityId(boardId, {
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
});
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ boardId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
name: z.string().max(200),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { boardId } = await params;
|
||||
const { name } = body;
|
||||
|
||||
if (!(await canUpdateBoard(auth, boardId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const share = await createShare({
|
||||
id: uuid(),
|
||||
entityId: boardId,
|
||||
shareType: ENTITY_TYPE.board,
|
||||
name,
|
||||
slug: getRandomChars(16),
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
return json(share);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { BOARD_TYPES } from '@/lib/boards';
|
||||
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
@@ -28,7 +28,9 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
type: z.enum([BOARD_TYPES.open, BOARD_TYPES.website, BOARD_TYPES.pixel, BOARD_TYPES.link]),
|
||||
type: z
|
||||
.enum([BOARD_TYPES.mixed, BOARD_TYPES.website, BOARD_TYPES.pixel, BOARD_TYPES.link])
|
||||
.or(z.literal('open')),
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
slug: z.string().max(100),
|
||||
@@ -58,6 +60,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const data = {
|
||||
...body,
|
||||
type: normalizeBoardType(body.type),
|
||||
id: uuid(),
|
||||
parameters: body.parameters ?? {},
|
||||
slug: uuid(),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { ENTITY_TYPE, ROLES } from '@/lib/constants';
|
||||
import { secret } from '@/lib/crypto';
|
||||
import { createToken } from '@/lib/jwt';
|
||||
import { getBoardWebsiteIds } from '@/lib/boards';
|
||||
import prisma from '@/lib/prisma';
|
||||
import redis from '@/lib/redis';
|
||||
import { json, notFound } from '@/lib/response';
|
||||
import type { WhiteLabel } from '@/lib/types';
|
||||
import { getShareByCode, getWebsite } from '@/queries/prisma';
|
||||
import { getBoard, getShareByCode, getWebsite } from '@/queries/prisma';
|
||||
|
||||
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
|
||||
if (website.userId) {
|
||||
@@ -52,10 +53,35 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (share.shareType === ENTITY_TYPE.board) {
|
||||
const board = await getBoard(share.entityId);
|
||||
|
||||
if (!board) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {
|
||||
shareId: share.id,
|
||||
shareType: share.shareType,
|
||||
boardId: share.entityId,
|
||||
parameters: share.parameters,
|
||||
websiteIds: getBoardWebsiteIds(board),
|
||||
};
|
||||
|
||||
data.token = createToken(data, secret());
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
const website = await getWebsite(share.entityId);
|
||||
|
||||
if (!website) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {
|
||||
shareId: share.id,
|
||||
shareType: share.shareType,
|
||||
websiteId: share.entityId,
|
||||
parameters: share.parameters,
|
||||
};
|
||||
|
||||
@@ -3,12 +3,16 @@ import { Loading } from '@umami/react-zen';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { createContext, type ReactNode, useEffect } from 'react';
|
||||
import { useShareTokenQuery } from '@/components/hooks';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import type { WhiteLabel } from '@/lib/types';
|
||||
|
||||
export interface ShareData {
|
||||
shareId: string;
|
||||
slug: string;
|
||||
websiteId: string;
|
||||
shareType: number;
|
||||
websiteId?: string;
|
||||
websiteIds?: string[];
|
||||
boardId?: string;
|
||||
parameters: any;
|
||||
token: string;
|
||||
whiteLabel?: WhiteLabel;
|
||||
@@ -49,12 +53,13 @@ export function ShareProvider({ slug, children }: { slug: string; children: Reac
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const path = getSharePath(pathname);
|
||||
const isBoardShare = share?.shareType === ENTITY_TYPE.board;
|
||||
|
||||
const allowedSections = share?.parameters
|
||||
const allowedSections = !isBoardShare && share?.parameters
|
||||
? ALL_SECTION_IDS.filter(id => share.parameters[id] !== false)
|
||||
: [];
|
||||
|
||||
const shouldRedirect =
|
||||
const shouldRedirect = !isBoardShare &&
|
||||
allowedSections.length === 1 &&
|
||||
allowedSections[0] !== 'overview' &&
|
||||
(path === undefined || path === '' || path === 'overview');
|
||||
|
||||
@@ -17,9 +17,11 @@ import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/Session
|
||||
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
|
||||
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||
import { BoardViewPage } from '@/app/(main)/boards/[boardId]/BoardViewPage';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { useShare } from '@/components/hooks';
|
||||
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import { ShareNav } from './ShareNav';
|
||||
|
||||
const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
|
||||
@@ -57,7 +59,7 @@ export function SharePage() {
|
||||
const { setTheme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const path = getSharePath(pathname);
|
||||
const { websiteId, parameters = {} } = share;
|
||||
const { websiteId, boardId, parameters = {}, shareType } = share;
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window?.location?.href);
|
||||
@@ -66,7 +68,18 @@ export function SharePage() {
|
||||
if (theme === 'light' || theme === 'dark') {
|
||||
setTheme(theme);
|
||||
}
|
||||
}, []);
|
||||
}, [setTheme]);
|
||||
|
||||
if (shareType === ENTITY_TYPE.board && boardId) {
|
||||
return (
|
||||
<BoardViewPage
|
||||
boardId={boardId}
|
||||
showActions={false}
|
||||
showControls={false}
|
||||
showEntityBadges={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the requested path is allowed
|
||||
const pageKey = path || '';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Button, Icon } from '@umami/react-zen';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from '@/components/icons';
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = 'Copy',
|
||||
}: {
|
||||
value: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!navigator?.clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="quiet" onPress={handleCopy} title={label} aria-label={label}>
|
||||
<Icon size="sm">{copied ? <Check /> : <Copy />}</Icon>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export * from './context/useWebsite';
|
||||
// Query hooks
|
||||
export * from './queries/useActiveUsersQuery';
|
||||
export * from './queries/useBoardQuery';
|
||||
export * from './queries/useBoardSharesQuery';
|
||||
export * from './queries/useBoardsQuery';
|
||||
export * from './queries/useDashboardQuery';
|
||||
export * from './queries/useDateRangeQuery';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
export function useBoardSharesQuery({ boardId }: { boardId: string }, options?: ReactQueryOptions) {
|
||||
const { modified } = useModified('shares');
|
||||
const { get } = useApi();
|
||||
|
||||
return usePagedQuery({
|
||||
queryKey: ['boardShares', { boardId, modified }],
|
||||
queryFn: pageParams => {
|
||||
return get(`/boards/${boardId}/shares`, pageParams);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -55,7 +55,11 @@ export function DialogButton({
|
||||
<IconLabel icon={icon} label={label} />
|
||||
</Button>
|
||||
<Modal placement={isMobile ? 'fullscreen' : 'center'}>
|
||||
<Dialog variant={isMobile ? 'sheet' : undefined} title={title || label} style={style}>
|
||||
<Dialog
|
||||
variant={isMobile ? 'sheet' : undefined}
|
||||
title={title === undefined ? label : title}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
|
||||
+46
-5
@@ -2,7 +2,7 @@ import type { Board, BoardComponentConfig, BoardParameters } from './types';
|
||||
|
||||
export const BOARD_TYPES = {
|
||||
dashboard: 'dashboard',
|
||||
open: 'open',
|
||||
mixed: 'mixed',
|
||||
website: 'website',
|
||||
pixel: 'pixel',
|
||||
link: 'link',
|
||||
@@ -32,7 +32,19 @@ export function getLegacyBoardType(parameters?: BoardParameters): BoardType {
|
||||
return BOARD_TYPES.website;
|
||||
}
|
||||
|
||||
return BOARD_TYPES.open;
|
||||
return BOARD_TYPES.mixed;
|
||||
}
|
||||
|
||||
export function normalizeBoardType(type?: string): BoardType | undefined {
|
||||
if (type === 'open') {
|
||||
return BOARD_TYPES.mixed;
|
||||
}
|
||||
|
||||
if (type && boardTypes.has(type)) {
|
||||
return type as BoardType;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getBoardType(
|
||||
@@ -45,15 +57,17 @@ export function getBoardType(
|
||||
return getLegacyBoardType(board?.parameters);
|
||||
}
|
||||
|
||||
if (type && boardTypes.has(type)) {
|
||||
return type as BoardType;
|
||||
const normalizedType = normalizeBoardType(type);
|
||||
|
||||
if (normalizedType) {
|
||||
return normalizedType;
|
||||
}
|
||||
|
||||
return getLegacyBoardType(board?.parameters);
|
||||
}
|
||||
|
||||
export function isOpenBoardType(type?: string) {
|
||||
return type === BOARD_TYPES.open || type === BOARD_TYPES.dashboard;
|
||||
return type === BOARD_TYPES.mixed || type === BOARD_TYPES.dashboard || type === 'open';
|
||||
}
|
||||
|
||||
export function requiresBoardEntity(type?: string) {
|
||||
@@ -142,6 +156,33 @@ export function getFirstBoardComponentEntity(
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getBoardWebsiteIds(
|
||||
board?: Pick<Board, 'type' | 'parameters'> | Partial<Board>,
|
||||
): string[] {
|
||||
const ids = new Set<string>();
|
||||
const boardEntity = getBoardEntity(board);
|
||||
|
||||
if (boardEntity.entityType === BOARD_ENTITY_TYPES.website && boardEntity.entityId) {
|
||||
ids.add(boardEntity.entityId);
|
||||
}
|
||||
|
||||
if (board?.parameters?.websiteId) {
|
||||
ids.add(board.parameters.websiteId);
|
||||
}
|
||||
|
||||
for (const row of board?.parameters?.rows ?? []) {
|
||||
for (const column of row.columns ?? []) {
|
||||
const entity = getComponentEntity(column.component);
|
||||
|
||||
if (entity.entityType === BOARD_ENTITY_TYPES.website && entity.entityId) {
|
||||
ids.add(entity.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function clearBoardEntity(parameters: BoardParameters = {}): BoardParameters {
|
||||
const { websiteId, pixelId, linkId, ...rest } = parameters;
|
||||
|
||||
|
||||
+10
-7
@@ -1,12 +1,15 @@
|
||||
import type { Link, Pixel, Website } from '@/generated/prisma/client';
|
||||
import { getLink, getPixel, getWebsite } from '@/queries/prisma';
|
||||
import type { Board, Link, Pixel, Website } from '@/generated/prisma/client';
|
||||
import { getBoard, getLink, getPixel, getWebsite } from '@/queries/prisma';
|
||||
|
||||
export async function getEntity(entityId: string): Promise<Website | Link | Pixel | null> {
|
||||
const website = await getWebsite(entityId);
|
||||
const link = await getLink(entityId);
|
||||
const pixel = await getPixel(entityId);
|
||||
export async function getEntity(entityId: string): Promise<Website | Link | Pixel | Board | null> {
|
||||
const [website, link, pixel, board] = await Promise.all([
|
||||
getWebsite(entityId),
|
||||
getLink(entityId),
|
||||
getPixel(entityId),
|
||||
getBoard(entityId),
|
||||
]);
|
||||
|
||||
const entity = website || link || pixel;
|
||||
const entity = website || link || pixel || board;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@ export interface Auth {
|
||||
isAdmin: boolean;
|
||||
};
|
||||
shareToken?: {
|
||||
websiteId: string;
|
||||
websiteId?: string;
|
||||
websiteIds?: string[];
|
||||
boardId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,25 @@ import { PERMISSIONS } from '@/lib/constants';
|
||||
import type { Auth } from '@/lib/types';
|
||||
import { getBoard, getTeamUser } from '@/queries/prisma';
|
||||
|
||||
export async function canViewBoard({ user }: Auth, boardId: string) {
|
||||
export async function canViewBoard({ user, shareToken }: Auth, boardId: string) {
|
||||
if (user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shareToken?.boardId === boardId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const board = await getBoard(boardId);
|
||||
|
||||
if (!board) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (board.userId) {
|
||||
return user.id === board.userId;
|
||||
}
|
||||
@@ -24,12 +36,20 @@ export async function canViewBoard({ user }: Auth, boardId: string) {
|
||||
}
|
||||
|
||||
export async function canUpdateBoard({ user }: Auth, boardId: string) {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const board = await getBoard(boardId);
|
||||
|
||||
if (!board) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (board.userId) {
|
||||
return user.id === board.userId;
|
||||
}
|
||||
@@ -44,12 +64,20 @@ export async function canUpdateBoard({ user }: Auth, boardId: string) {
|
||||
}
|
||||
|
||||
export async function canDeleteBoard({ user }: Auth, boardId: string) {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const board = await getBoard(boardId);
|
||||
|
||||
if (!board) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (board.userId) {
|
||||
return user.id === board.userId;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ export async function canViewEntity({ user }: Auth, entityId: string) {
|
||||
|
||||
const entity = await getEntity(entityId);
|
||||
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entity.userId) {
|
||||
return user.id === entity.userId;
|
||||
}
|
||||
@@ -39,6 +43,10 @@ export async function canUpdateEntity({ user }: Auth, entityId: string) {
|
||||
|
||||
const entity = await getEntity(entityId);
|
||||
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entity.userId) {
|
||||
return user.id === entity.userId;
|
||||
}
|
||||
@@ -63,6 +71,10 @@ export async function canDeleteEntity({ user }: Auth, entityId: string) {
|
||||
|
||||
const entity = await getEntity(entityId);
|
||||
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entity.userId) {
|
||||
return user.id === entity.userId;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shareToken?.websiteId === websiteId) {
|
||||
if (shareToken?.websiteId === websiteId || shareToken?.websiteIds?.includes(websiteId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user