feat: init
This commit is contained in:
53
apps/web/core/components/common/access-field.tsx
Normal file
53
apps/web/core/components/common/access-field.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
apps/web/core/components/common/activity/activity-block.tsx
Normal file
56
apps/web/core/components/common/activity/activity-block.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
apps/web/core/components/common/activity/activity-item.tsx
Normal file
32
apps/web/core/components/common/activity/activity-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
281
apps/web/core/components/common/activity/helper.tsx
Normal file
281
apps/web/core/components/common/activity/helper.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Signal,
|
||||
RotateCcw,
|
||||
Network,
|
||||
Link as LinkIcon,
|
||||
Calendar,
|
||||
Tag,
|
||||
Inbox,
|
||||
AlignLeft,
|
||||
Users,
|
||||
Paperclip,
|
||||
Type,
|
||||
Triangle,
|
||||
FileText,
|
||||
Globe,
|
||||
Hash,
|
||||
Clock,
|
||||
Bell,
|
||||
LayoutGrid,
|
||||
GitBranch,
|
||||
Timer,
|
||||
ListTodo,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
// components
|
||||
import { ArchiveIcon, CycleIcon, DoubleCircleIcon, IntakeIcon, ModuleIcon } 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: <Signal size={14} className="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: <Calendar className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
target_date: <Calendar className="h-3.5 w-3.5 text-custom-text-200" />,
|
||||
label: <Tag 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: <Users 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: <DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
|
||||
estimate: <Triangle size={14} className="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, " ")} `,
|
||||
};
|
||||
}
|
||||
};
|
||||
38
apps/web/core/components/common/activity/user.tsx
Normal file
38
apps/web/core/components/common/activity/user.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
54
apps/web/core/components/common/applied-filters/date.tsx
Normal file
54
apps/web/core/components/common/applied-filters/date.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { X } from "lucide-react";
|
||||
// plane constants
|
||||
import { DATE_BEFORE_FILTER_OPTIONS } from "@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)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
55
apps/web/core/components/common/applied-filters/members.tsx
Normal file
55
apps/web/core/components/common/applied-filters/members.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// 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)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
76
apps/web/core/components/common/breadcrumb-link.tsx
Normal file
76
apps/web/core/components/common/breadcrumb-link.tsx
Normal 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";
|
||||
25
apps/web/core/components/common/count-chip.tsx
Normal file
25
apps/web/core/components/common/count-chip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
apps/web/core/components/common/empty-state.tsx
Normal file
50
apps/web/core/components/common/empty-state.tsx
Normal 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>
|
||||
);
|
||||
76
apps/web/core/components/common/filters/created-at.tsx
Normal file
76
apps/web/core/components/common/filters/created-at.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
111
apps/web/core/components/common/filters/created-by.tsx
Normal file
111
apps/web/core/components/common/filters/created-by.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
40
apps/web/core/components/common/latest-feature-block.tsx
Normal file
40
apps/web/core/components/common/latest-feature-block.tsx
Normal 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 "@/public/onboarding/onboarding-pages.webp";
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
17
apps/web/core/components/common/logo-spinner.tsx
Normal file
17
apps/web/core/components/common/logo-spinner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif";
|
||||
import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
103
apps/web/core/components/common/logo.tsx
Normal file
103
apps/web/core/components/common/logo.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
// Due to some weird issue with the import order, the import of useFontFaceObserver
|
||||
// should be after the imported here rather than some below helper functions as it is in the original file
|
||||
// eslint-disable-next-line import/order
|
||||
import useFontFaceObserver from "use-font-face-observer";
|
||||
// plane imports
|
||||
import { getEmojiSize, LUCIDE_ICONS_LIST, stringToEmoji } from "@plane/propel/emoji-icon-picker";
|
||||
import type { TLogoProps } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
logo: TLogoProps;
|
||||
size?: number;
|
||||
type?: "lucide" | "material";
|
||||
};
|
||||
|
||||
export const Logo: FC<Props> = (props) => {
|
||||
const { logo, size = 16, type = "material" } = props;
|
||||
|
||||
// destructuring the logo object
|
||||
const { in_use, emoji, icon } = logo;
|
||||
|
||||
// derived values
|
||||
const value = in_use === "emoji" ? emoji?.value : icon?.name;
|
||||
const color = icon?.color;
|
||||
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
|
||||
|
||||
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
|
||||
{
|
||||
family: `Material Symbols Rounded`,
|
||||
style: `normal`,
|
||||
weight: `normal`,
|
||||
stretch: `condensed`,
|
||||
},
|
||||
]);
|
||||
// if no value, return empty fragment
|
||||
if (!value) return <></>;
|
||||
|
||||
if (!isMaterialSymbolsFontLoaded) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
className="rounded animate-pulse bg-custom-background-80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// emoji
|
||||
if (in_use === "emoji") {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
fontSize: `${getEmojiSize(size)}rem`,
|
||||
lineHeight: `${getEmojiSize(size)}rem`,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
>
|
||||
{stringToEmoji(emoji?.value || "")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// icon
|
||||
if (in_use === "icon") {
|
||||
return (
|
||||
<>
|
||||
{type === "lucide" ? (
|
||||
<>
|
||||
{lucideIcon && (
|
||||
<lucideIcon.element
|
||||
style={{
|
||||
color: color,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-rounded"
|
||||
style={{
|
||||
fontSize: size,
|
||||
color: color,
|
||||
scale: "115%",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// if no value, return empty fragment
|
||||
return <></>;
|
||||
};
|
||||
114
apps/web/core/components/common/new-empty-state.tsx
Normal file
114
apps/web/core/components/common/new-empty-state.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
apps/web/core/components/common/page-access-icon.tsx
Normal file
15
apps/web/core/components/common/page-access-icon.tsx
Normal 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>
|
||||
);
|
||||
16
apps/web/core/components/common/pro-icon.tsx
Normal file
16
apps/web/core/components/common/pro-icon.tsx
Normal 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)} />;
|
||||
};
|
||||
55
apps/web/core/components/common/switcher-label.tsx
Normal file
55
apps/web/core/components/common/switcher-label.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FC } from "react";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TLogoProps } from "@plane/types";
|
||||
import { getFileURL, truncateText } from "@plane/utils";
|
||||
import { Logo } from "@/components/common/logo";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user