Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import type { LucideIcon } from "lucide-react";
// plane ui
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
type Props = {
onChange: (value: number) => void;
value: number;
accessSpecifiers: {
key: number;
i18n_label?: string;
label?: string;
icon: LucideIcon;
}[];
isMobile?: boolean;
};
// TODO: Remove label once i18n is done
export const AccessField = (props: Props) => {
const { onChange, value, accessSpecifiers, isMobile = false } = props;
const { t } = useTranslation();
return (
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[1px] border-custom-border-200 p-1">
{accessSpecifiers.map((access, index) => {
const label = access.i18n_label ? t(access.i18n_label) : access.label;
return (
<Tooltip key={access.key} tooltipContent={label} isMobile={isMobile}>
<button
type="button"
onClick={() => onChange(access.key)}
className={cn(
"flex-shrink-0 relative flex justify-center items-center w-5 h-5 rounded-sm p-1 transition-all",
value === access.key ? "bg-custom-background-80" : "hover:bg-custom-background-80"
)}
tabIndex={2 + index}
>
<access.icon
className={cn(
"h-3.5 w-3.5 transition-all",
value === access.key ? "text-custom-text-100" : "text-custom-text-400"
)}
strokeWidth={2}
/>
</button>
</Tooltip>
);
})}
</div>
);
};

View File

@@ -0,0 +1,56 @@
"use client";
import type { FC, ReactNode } from "react";
import { Network } from "lucide-react";
// types
import { Tooltip } from "@plane/propel/tooltip";
import type { TWorkspaceBaseActivity } from "@plane/types";
// ui
// helpers
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
import { User } from "./user";
type TActivityBlockComponent = {
icon?: ReactNode;
activity: TWorkspaceBaseActivity;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};
export const ActivityBlockComponent: FC<TActivityBlockComponent> = (props) => {
const { icon, activity, ends, children, customUserName } = props;
// hooks
const { isMobile } = usePlatformOS();
if (!activity) return <></>;
return (
<div
className={`relative flex items-start gap-2 text-xs ${
ends === "top" ? `pb-3` : ends === "bottom" ? `pt-3` : `py-3`
}`}
>
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-start mt-0.5 z-[4] text-custom-text-200">
{icon ? icon : <Network className="w-3.5 h-3.5" />}
</div>
<div className="w-full text-custom-text-200">
<div className="line-clamp-2">
<User activity={activity} customUserName={customUserName} /> {children}
</div>
<div className="mt-1">
<Tooltip
isMobile={isMobile}
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap text-custom-text-350 font-medium cursor-help">
{calculateTimeAgo(activity.created_at)}
</span>
</Tooltip>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,32 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import type { TProjectActivity } from "@/plane-web/types";
import { ActivityBlockComponent } from "./activity-block";
import { iconsMap, messages } from "./helper";
type TActivityItem = {
activity: TProjectActivity;
showProject?: boolean;
ends?: "top" | "bottom" | undefined;
};
export const ActivityItem: FC<TActivityItem> = observer((props) => {
const { activity, showProject = true, ends } = props;
if (!activity) return null;
const activityType = activity.field;
if (!activityType) return null;
const { message, customUserName } = messages(activity);
const icon = iconsMap[activityType] || iconsMap.default;
return (
<ActivityBlockComponent icon={icon} activity={activity} ends={ends} customUserName={customUserName}>
<>{message}</>
</ActivityBlockComponent>
);
});

View File

@@ -0,0 +1,289 @@
import type { ReactNode } from "react";
import {
RotateCcw,
Network,
Link as LinkIcon,
Calendar,
Inbox,
AlignLeft,
Paperclip,
Type,
FileText,
Globe,
Hash,
Clock,
Bell,
LayoutGrid,
GitBranch,
Timer,
ListTodo,
Layers,
} from "lucide-react";
// components
import {
ArchiveIcon,
CycleIcon,
StatePropertyIcon,
IntakeIcon,
ModuleIcon,
PriorityPropertyIcon,
StartDatePropertyIcon,
DueDatePropertyIcon,
LabelPropertyIcon,
MembersPropertyIcon,
EstimatePropertyIcon,
} from "@plane/propel/icons";
import { store } from "@/lib/store-context";
import type { TProjectActivity } from "@/plane-web/types";
type ActivityIconMap = {
[key: string]: ReactNode;
};
export const iconsMap: ActivityIconMap = {
priority: <PriorityPropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
archived_at: <ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" />,
restored: <RotateCcw className="h-3.5 w-3.5 text-custom-text-200" />,
link: <LinkIcon className="h-3.5 w-3.5 text-custom-text-200" />,
start_date: <StartDatePropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
target_date: <DueDatePropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
label: <LabelPropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
inbox: <Inbox className="h-3.5 w-3.5 text-custom-text-200" />,
description: <AlignLeft className="h-3.5 w-3.5 text-custom-text-200" />,
assignee: <MembersPropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
attachment: <Paperclip className="h-3.5 w-3.5 text-custom-text-200" />,
name: <Type className="h-3.5 w-3.5 text-custom-text-200" />,
state: <StatePropertyIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
estimate: <EstimatePropertyIcon className="h-3.5 w-3.5 text-custom-text-200" />,
cycle: <CycleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
module: <ModuleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
page: <FileText className="h-3.5 w-3.5 text-custom-text-200" />,
network: <Globe className="h-3.5 w-3.5 text-custom-text-200" />,
identifier: <Hash className="h-3.5 w-3.5 text-custom-text-200" />,
timezone: <Clock className="h-3.5 w-3.5 text-custom-text-200" />,
is_project_updates_enabled: <Bell className="h-3.5 w-3.5 text-custom-text-200" />,
is_epic_enabled: <LayoutGrid className="h-3.5 w-3.5 text-custom-text-200" />,
is_workflow_enabled: <GitBranch className="h-3.5 w-3.5 text-custom-text-200" />,
is_time_tracking_enabled: <Timer className="h-3.5 w-3.5 text-custom-text-200" />,
is_issue_type_enabled: <ListTodo className="h-3.5 w-3.5 text-custom-text-200" />,
default: <Network className="h-3.5 w-3.5 text-custom-text-200" />,
module_view: <ModuleIcon className="h-3.5 w-3.5 text-custom-text-200" />,
cycle_view: <CycleIcon className="h-3.5 w-3.5 text-custom-text-200" />,
issue_views_view: <Layers className="h-3.5 w-3.5 text-custom-text-200" />,
page_view: <FileText className="h-3.5 w-3.5 text-custom-text-200" />,
intake_view: <IntakeIcon className="h-3.5 w-3.5 text-custom-text-200" />,
};
export const messages = (activity: TProjectActivity): { message: string | ReactNode; customUserName?: string } => {
const activityType = activity.field;
const newValue = activity.new_value;
const oldValue = activity.old_value;
const verb = activity.verb;
const workspaceDetail = store.workspaceRoot.getWorkspaceById(activity.workspace);
const getBooleanActionText = (value: string | undefined) => {
if (value === "true") return "enabled";
if (value === "false") return "disabled";
return verb;
};
switch (activityType) {
case "priority":
return {
message: (
<>
set the priority to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
</>
),
};
case "archived_at":
return {
message: newValue === "restore" ? "restored the project" : "archived the project",
customUserName: newValue === "archive" ? "Plane" : undefined,
};
case "name":
return {
message: (
<>
renamed the project to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
),
};
case "description":
return {
message: newValue ? "updated the project description" : "removed the project description",
};
case "start_date":
return {
message: (
<>
{newValue ? (
<>
set the start date to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
) : (
"removed the start date"
)}
</>
),
};
case "target_date":
return {
message: (
<>
{newValue ? (
<>
set the target date to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
) : (
"removed the target date"
)}
</>
),
};
case "state":
return {
message: (
<>
set the state to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
</>
),
};
case "estimate":
return {
message: (
<>
{newValue ? (
<>
set the estimate point to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
) : (
<>
removed the estimate point
{oldValue && (
<>
{" "}
<span className="font-medium text-custom-text-100">{oldValue}</span>
</>
)}
</>
)}
</>
),
};
case "cycles":
return {
message: (
<>
<span>
{verb} this project {verb === "removed" ? "from" : "to"} the cycle{" "}
</span>
{verb !== "removed" ? (
<a
href={`/${workspaceDetail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex font-medium text-custom-text-100"
>
{activity.new_value}
</a>
) : (
<span className="font-medium text-custom-text-100">{activity.old_value || "Unknown cycle"}</span>
)}
</>
),
};
case "modules":
return {
message: (
<>
<span>
{verb} this project {verb === "removed" ? "from" : "to"} the module{" "}
</span>
<span className="font-medium text-custom-text-100">
{verb === "removed" ? oldValue : newValue || "Unknown module"}
</span>
</>
),
};
case "labels":
return {
message: (
<>
{verb} the label{" "}
<span className="font-medium text-custom-text-100">{newValue || oldValue || "Untitled label"}</span>
</>
),
};
case "inbox":
return {
message: <>{newValue ? "enabled" : "disabled"} inbox</>,
};
case "page":
return {
message: (
<>
{newValue ? "created" : "removed"} the project page{" "}
<span className="font-medium text-custom-text-100">{newValue || oldValue || "Untitled page"}</span>
</>
),
};
case "network":
return {
message: <>{newValue ? "enabled" : "disabled"} network access</>,
};
case "identifier":
return {
message: (
<>
updated project identifier to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
</>
),
};
case "timezone":
return {
message: (
<>
changed project timezone to{" "}
<span className="font-medium text-custom-text-100">{newValue || "default"}</span>
</>
),
};
case "module_view":
case "cycle_view":
case "issue_views_view":
case "page_view":
case "intake_view":
return {
message: (
<>
{getBooleanActionText(newValue)} {activityType.replace(/_view$/, "").replace(/_/g, " ")} view
</>
),
};
case "is_project_updates_enabled":
return {
message: <>{getBooleanActionText(newValue)} project updates</>,
};
case "is_epic_enabled":
return {
message: <>{getBooleanActionText(newValue)} epics</>,
};
case "is_workflow_enabled":
return {
message: <>{getBooleanActionText(newValue)} custom workflow</>,
};
case "is_time_tracking_enabled":
return {
message: <>{getBooleanActionText(newValue)} time tracking</>,
};
case "is_issue_type_enabled":
return {
message: <>{getBooleanActionText(newValue)} work item types</>,
};
default:
return {
message: `${verb} ${activityType?.replace(/_/g, " ")} `,
};
}
};

View File

@@ -0,0 +1,38 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// types
import type { TWorkspaceBaseActivity } from "@plane/types";
// store hooks
import { useMember } from "@/hooks/store/use-member";
import { useWorkspace } from "@/hooks/store/use-workspace";
type TUser = {
activity: TWorkspaceBaseActivity;
customUserName?: string;
};
export const User: FC<TUser> = observer((props) => {
const { activity, customUserName } = props;
// store hooks
const { getUserDetails } = useMember();
const { getWorkspaceById } = useWorkspace();
// derived values
const actorDetail = getUserDetails(activity.actor);
const workspaceDetail = getWorkspaceById(activity.workspace);
return (
<>
{customUserName || actorDetail?.display_name.includes("-intake") ? (
<span className="text-custom-text-100 font-medium">{customUserName || "Plane"}</span>
) : (
<Link
href={`/${workspaceDetail?.slug}/profile/${actorDetail?.id}`}
className="hover:underline text-custom-text-100 font-medium"
>
{actorDetail?.display_name}
</Link>
)}
</>
);
});

View File

@@ -0,0 +1,54 @@
import { observer } from "mobx-react";
// icons
import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants";
import { CloseIcon } from "@plane/propel/icons";
// plane constants
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
// helpers
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<CloseIcon height={10} width={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import { observer } from "mobx-react";
import { CloseIcon } from "@plane/propel/icons";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// types
import { useMember } from "@/hooks/store/use-member";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
// store hooks
const {
workspace: { getWorkspaceMemberDetails },
} = useMember();
return (
<>
{values.map((memberId) => {
const memberDetails = getWorkspaceMemberDetails(memberId)?.member;
if (!memberDetails) return null;
return (
<div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs">
<Avatar
name={memberDetails.display_name}
src={getFileURL(memberDetails.avatar_url)}
showTooltip={false}
size={"sm"}
/>
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(memberId)}
>
<CloseIcon height={10} width={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,76 @@
"use client";
import type { ReactNode, FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Breadcrumbs } from "@plane/ui";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
label?: string;
href?: string;
icon?: React.ReactNode;
disableTooltip?: boolean;
isLast?: boolean;
};
const IconWrapper = React.memo(({ icon }: { icon: React.ReactNode }) => (
<div className="flex size-4 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
));
IconWrapper.displayName = "IconWrapper";
const LabelWrapper = React.memo(({ label }: { label: ReactNode }) => (
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
));
LabelWrapper.displayName = "LabelWrapper";
const BreadcrumbContent = React.memo(({ icon, label }: { icon?: React.ReactNode; label?: ReactNode }) => {
if (!icon && !label) return null;
return (
<>
{icon && <IconWrapper icon={icon} />}
{label && <LabelWrapper label={label} />}
</>
);
});
BreadcrumbContent.displayName = "BreadcrumbContent";
const ItemWrapper = React.memo(({ children, ...props }: React.ComponentProps<typeof Breadcrumbs.ItemWrapper>) => (
<Breadcrumbs.ItemWrapper {...props}>{children}</Breadcrumbs.ItemWrapper>
));
ItemWrapper.displayName = "ItemWrapper";
export const BreadcrumbLink: FC<Props> = observer((props) => {
const { href, label, icon, disableTooltip = false, isLast = false } = props;
const { isMobile } = usePlatformOS();
const itemWrapperProps = useMemo(
() => ({
label: label?.toString(),
disableTooltip: isMobile || disableTooltip,
type: (href && href !== "" ? "link" : "text") as "link" | "text",
isLast,
}),
[href, label, isMobile, disableTooltip, isLast]
);
const content = useMemo(() => <BreadcrumbContent icon={icon} label={label} />, [icon, label]);
if (href) {
return (
<Link href={href}>
<ItemWrapper {...itemWrapperProps}>{content}</ItemWrapper>
</Link>
);
}
return <ItemWrapper {...itemWrapperProps}>{content}</ItemWrapper>;
});
BreadcrumbLink.displayName = "BreadcrumbLink";

View File

@@ -0,0 +1,25 @@
"use client";
import type { FC } from "react";
//
import { cn } from "@plane/utils";
type TCountChip = {
count: string | number;
className?: string;
};
export const CountChip: FC<TCountChip> = (props) => {
const { count, className = "" } = props;
return (
<div
className={cn(
"relative flex justify-center items-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl",
className
)}
>
{count}
</div>
);
};

View File

@@ -0,0 +1,50 @@
"use client";
import React from "react";
import Image from "next/image";
// ui
import { Button } from "@plane/propel/button";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export const EmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
secondaryButton,
disabled = false,
}) => (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
<Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,76 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants";
import { isInDateFormat } from "@plane/utils";
// components
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterCreatedDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Created date"
/>
)}
<FilterHeader
title={`Created date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,111 @@
"use client";
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// ui
import { Avatar, Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterCreatedBy: React.FC<Props> = observer((props: Props) => {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Created by${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`member-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
name={member.display_name}
src={getFileURL(member.avatar_url)}
showTooltip={false}
size="md"
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,40 @@
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
// icons
import { Lightbulb } from "lucide-react";
// images
import latestFeatures from "@/app/assets/onboarding/onboarding-pages.webp?url";
export const LatestFeatureBlock = () => {
const { resolvedTheme } = useTheme();
return (
<>
<div className="mx-auto mt-16 flex rounded-[3.5px] border border-custom-border-200 bg-custom-background-100 py-2 sm:w-96">
<Lightbulb className="mx-3 mr-2 h-7 w-7" />
<p className="text-left text-sm text-custom-text-100">
Pages gets a facelift! Write anything and use Galileo to help you start.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="text-sm font-medium underline hover:cursor-pointer">Learn more</span>
</Link>
</p>
</div>
<div
className={`mx-auto mt-8 overflow-hidden rounded-md border border-custom-border-200 object-cover sm:h-52 sm:w-96 ${
resolvedTheme === "dark" ? "bg-custom-background-100" : "bg-custom-primary-70"
}`}
>
<div className="h-[90%]">
<Image
src={latestFeatures}
alt="Plane Work items"
className={`-mt-2 ml-10 h-full rounded-md ${
resolvedTheme === "dark" ? "bg-custom-background-100" : "bg-custom-primary-70"
}`}
/>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,17 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
export const LogoSpinner = () => {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
</div>
);
};

View File

@@ -0,0 +1,114 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
// ui
import { Button } from "@plane/propel/button";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
comicBox?: {
direction: "left" | "right";
title: string;
description: string;
extraPadding?: boolean;
};
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
disabled?: boolean;
};
export const NewEmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
disabled = false,
comicBox,
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
return (
<div className="flex items-center justify-center overflow-y-auto">
<div className=" flex h-full w-full flex-col items-center justify-center ">
<div className="m-5 flex max-w-6xl flex-col gap-5 rounded-xl border border-custom-border-200 px-10 py-7 shadow-sm md:m-8">
<h3 className="text-2xl font-semibold">{title}</h3>
{description && <p className=" text-lg">{description}</p>}
<div className="relative w-full max-w-6xl">
<Image src={image} className="w-full" alt={primaryButton?.text || "button image"} />
</div>
<div className="relative flex items-start justify-center">
{primaryButton && (
<Button
className={`relative m-3 max-w-min !px-6 ${comicBox?.direction === "left" ? "flex-row-reverse" : ""}`}
size="lg"
variant="primary"
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`absolute bg-blue-300 ${
comicBox?.direction === "left" ? "left-0 ml-2" : "right-0 mr-2"
} z-10 h-2.5 w-2.5 animate-ping rounded-full`}
/>
<div
className={`absolute bg-blue-400/40 ${
comicBox?.direction === "left" ? "left-0 ml-2.5" : "right-0 mr-2.5"
} h-1.5 w-1.5 rounded-full`}
/>
</Button>
)}
{comicBox &&
isHovered &&
(comicBox.direction === "right" ? (
<div
className={`absolute left-1/2 top-0 flex max-w-sm ${
comicBox?.extraPadding ? "ml-[125px]" : "ml-[90px]"
} pb-5`}
>
<div className="relative mt-5 h-0 w-0 border-b-[11px] border-r-[11px] border-t-[11px] border-custom-border-200 border-y-transparent">
<div className="absolute right-[-12px] top-[-10px] h-0 w-0 border-b-[10px] border-r-[10px] border-t-[10px] border-custom-background-100 border-y-transparent" />
</div>
<div className="rounded-md border border-custom-border-200 bg-custom-background-100">
<h1 className="p-5">
<h3 className="text-lg font-semibold">{comicBox?.title}</h3>
<h4 className="mt-1 text-sm">{comicBox?.description}</h4>
</h1>
</div>
</div>
) : (
<div className="absolute right-1/2 top-0 mr-[90px] flex max-w-sm flex-row-reverse pb-5">
<div className="relative mt-5 h-0 w-0 border-b-[11px] border-l-[11px] border-t-[11px] border-custom-border-200 border-y-transparent">
<div className="absolute left-[-12px] top-[-10px] h-0 w-0 border-b-[10px] border-l-[10px] border-t-[10px] border-custom-background-100 border-y-transparent" />
</div>
<div className="rounded-md border border-custom-border-200 bg-custom-background-100">
<h1 className="p-5">
<h3 className="text-lg font-semibold">{comicBox?.title}</h3>
<h4 className="mt-1 text-sm">{comicBox?.description}</h4>
</h1>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { ArchiveIcon, Earth, Lock } from "lucide-react";
import { EPageAccess } from "@plane/constants";
import type { TPage } from "@plane/types";
export const PageAccessIcon = (page: TPage) => (
<div>
{page.archived_at ? (
<ArchiveIcon className="h-2.5 w-2.5 text-custom-text-300" />
) : page.access === EPageAccess.PUBLIC ? (
<Earth className="h-2.5 w-2.5 text-custom-text-300" />
) : (
<Lock className="h-2.5 w-2.5 text-custom-text-300" />
)}
</div>
);

View File

@@ -0,0 +1,16 @@
"use client";
import type { FC } from "react";
import { Crown } from "lucide-react";
// helpers
import { cn } from "@plane/utils";
type TProIcon = {
className?: string;
};
export const ProIcon: FC<TProIcon> = (props) => {
const { className } = props;
return <Crown className={cn("inline-block h-3.5 w-3.5 text-amber-400", className)} />;
};

View File

@@ -0,0 +1,55 @@
import type { FC } from "react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { ISvgIcons } from "@plane/propel/icons";
import type { TLogoProps } from "@plane/types";
import { getFileURL, truncateText } from "@plane/utils";
type TSwitcherIconProps = {
logo_props?: TLogoProps;
logo_url?: string;
LabelIcon: FC<ISvgIcons>;
size?: number;
type?: "lucide" | "material";
};
export const SwitcherIcon: FC<TSwitcherIconProps> = ({
logo_props,
logo_url,
LabelIcon,
size = 12,
type = "lucide",
}) => {
if (logo_props?.in_use) {
return <Logo logo={logo_props} size={size} type={type} />;
}
if (logo_url) {
return (
<img
src={getFileURL(logo_url)}
alt="logo"
className="rounded-sm object-cover"
style={{ height: size, width: size }}
/>
);
}
return <LabelIcon height={size} width={size} />;
};
type TSwitcherLabelProps = {
logo_props?: TLogoProps;
logo_url?: string;
name?: string;
LabelIcon: FC<ISvgIcons>;
type?: "lucide" | "material";
};
export const SwitcherLabel: FC<TSwitcherLabelProps> = (props) => {
const { logo_props, name, LabelIcon, logo_url, type = "lucide" } = props;
return (
<div className="flex items-center gap-1 text-custom-text-200">
<SwitcherIcon logo_props={logo_props} logo_url={logo_url} LabelIcon={LabelIcon} type={type} />
{truncateText(name ?? "", 40)}
</div>
);
};