Merge branch 'stage-release' of https://github.com/makeplane/plane into stage-release

This commit is contained in:
vamsi 2022-12-22 23:31:02 +05:30
commit 054fbab887
125 changed files with 11717 additions and 591 deletions

View File

@ -21,7 +21,7 @@ class CycleSerializer(BaseSerializer):
class CycleIssueSerializer(BaseSerializer):
issue_details = IssueStateSerializer(read_only=True, source="issue")
issue_detail = IssueStateSerializer(read_only=True, source="issue")
class Meta:
model = CycleIssue

View File

@ -22,6 +22,8 @@ from plane.db.models import (
IssueBlocker,
CycleIssue,
Cycle,
Module,
ModuleIssue,
)
@ -75,6 +77,11 @@ class IssueCreateSerializer(BaseSerializer):
write_only=True,
required=False,
)
blocks_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = Issue
@ -92,6 +99,7 @@ class IssueCreateSerializer(BaseSerializer):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
project = self.context["project"]
issue = Issue.objects.create(**validated_data, project=project)
@ -144,6 +152,22 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if blocks is not None:
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=issue,
project=project,
workspace=project.workspace,
created_by=issue.created_by,
updated_by=issue.updated_by,
)
for block in blocks
],
batch_size=10,
)
return issue
def update(self, instance, validated_data):
@ -151,6 +175,7 @@ class IssueCreateSerializer(BaseSerializer):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
@ -203,6 +228,23 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
if blocks is not None:
IssueBlocker.objects.filter(blocked_by=instance).delete()
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
block=block,
blocked_by=instance,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
)
for block in blocks
],
batch_size=10,
)
return super().update(instance, validated_data)
@ -342,6 +384,37 @@ class IssueCycleDetailSerializer(BaseSerializer):
]
class ModuleBaseSerializer(BaseSerializer):
class Meta:
model = Module
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueModuleDetailSerializer(BaseSerializer):
module_detail = ModuleBaseSerializer(read_only=True, source="module")
class Meta:
model = ModuleIssue
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state")
@ -351,6 +424,7 @@ class IssueSerializer(BaseSerializer):
blocked_issues = BlockedIssueSerializer(read_only=True, many=True)
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True, many=True)
class Meta:
model = Issue

View File

@ -5,7 +5,7 @@ from rest_framework import serializers
from .base import BaseSerializer
from .user import UserLiteSerializer
from .project import ProjectSerializer
from .issue import IssueFlatSerializer
from .issue import IssueStateSerializer
from plane.db.models import User, Module, ModuleMember, ModuleIssue
@ -52,6 +52,7 @@ class ModuleWriteSerializer(BaseSerializer):
for member in members
],
batch_size=10,
ignore_conflicts=True,
)
return module
@ -59,25 +60,22 @@ class ModuleWriteSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("members_list", None)
project = self.context["project"]
module = Module.objects.create(**validated_data, project=project)
if members is not None:
ModuleIssue.objects.filter(module=instance).delete()
ModuleMember.objects.bulk_create(
[
ModuleMember(
module=module,
module=instance,
member=member,
project=project,
workspace=project.workspace,
created_by=module.created_by,
updated_by=module.updated_by,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
)
for member in members
],
batch_size=10,
ignore_conflicts=True
)
return super().update(instance, validated_data)
@ -100,7 +98,7 @@ class ModuleFlatSerializer(BaseSerializer):
class ModuleIssueSerializer(BaseSerializer):
module_detail = ModuleFlatSerializer(read_only=True, source="module")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
issue_detail = IssueStateSerializer(read_only=True, source="issue")
class Meta:
model = ModuleIssue

View File

@ -37,6 +37,7 @@ from plane.db.models import (
Label,
IssueBlocker,
CycleIssue,
ModuleIssue
)
@ -96,6 +97,12 @@ class IssueViewSet(BaseViewSet):
queryset=CycleIssue.objects.select_related("cycle", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
),
)
)
def grouper(self, issue, group_by):
@ -175,6 +182,7 @@ class UserWorkSpaceIssues(BaseAPIView):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class WorkSpaceIssuesEndpoint(BaseAPIView):
permission_classes = [

View File

@ -44,7 +44,7 @@ class ModuleViewSet(BaseViewSet):
.prefetch_related("members")
.prefetch_related(
Prefetch(
"module_issues",
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
)
)

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2.14 on 2022-12-22 09:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('db', '0013_projectmember_default_props'),
]
operations = [
migrations.AlterField(
model_name='moduleissue',
name='issue',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'),
),
migrations.AlterField(
model_name='moduleissue',
name='module',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.module'),
),
]

View File

@ -71,10 +71,10 @@ class ModuleMember(ProjectBaseModel):
class ModuleIssue(ProjectBaseModel):
module = models.ForeignKey(
"db.Module", on_delete=models.CASCADE, related_name="module_issues"
"db.Module", on_delete=models.CASCADE, related_name="issue_module"
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="module_issues"
"db.Issue", on_delete=models.CASCADE, related_name="issue_module"
)
class Meta:

View File

@ -1,18 +1,22 @@
// react
import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
import useToast from "lib/hooks/useToast";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/create-project-modal";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import {
FolderIcon,
@ -20,25 +24,10 @@ import {
ClipboardDocumentListIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/create-project-modal";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui
import { Button } from "ui";
// types
import { IIssue, IssueResponse } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// constants
import { IIssue } from "types";
// common
import { classNames, copyTextToClipboard } from "constants/common";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
type FormInput = {
issue_ids: string[];
cycleId: string;
};
const CommandPalette: React.FC = () => {
const [query, setQuery] = useState("");
@ -49,8 +38,9 @@ const CommandPalette: React.FC = () => {
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const [isCreateModuleModalOpen, setisCreateModuleModalOpen] = useState(false);
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
const { activeWorkspace, activeProject, issues } = useUser();
const { activeProject, issues } = useUser();
const router = useRouter();
@ -64,8 +54,6 @@ const CommandPalette: React.FC = () => {
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const { register, handleSubmit, reset } = useForm<FormInput>();
const quickActions = [
{
name: "Add new issue...",
@ -88,7 +76,6 @@ const CommandPalette: React.FC = () => {
const handleCommandPaletteClose = () => {
setIsPaletteOpen(false);
setQuery("");
reset();
};
const handleKeyDown = useCallback(
@ -114,6 +101,9 @@ const CommandPalette: React.FC = () => {
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
e.preventDefault();
setisCreateModuleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
e.preventDefault();
@ -138,47 +128,6 @@ const CommandPalette: React.FC = () => {
[toggleCollapsed, setToastAlert, router]
);
const handleDelete: SubmitHandler<FormInput> = (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data)
.then((res) => {
setToastAlert({
title: "Success",
type: "success",
message: res.message,
});
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
count: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
).length,
results: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
),
};
},
false
);
})
.catch((e) => {
console.log(e);
});
}
};
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
@ -207,6 +156,10 @@ const CommandPalette: React.FC = () => {
setIsOpen={setIsIssueModalOpen}
projectId={activeProject?.id}
/>
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}
setIsOpen={setIsBulkDeleteIssuesModalOpen}
/>
<Transition.Root
show={isPaletteOpen}
as={React.Fragment}
@ -283,12 +236,6 @@ const CommandPalette: React.FC = () => {
{({ active }) => (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
@ -377,9 +324,6 @@ const CommandPalette: React.FC = () => {
</Combobox>
<div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected issues
</Button>
<div>
<Button type="button" size="sm" onClick={handleCommandPaletteClose}>
Close

View File

@ -30,6 +30,8 @@ const shortcuts = [
{ keys: "ctrl,p", description: "To create project" },
{ keys: "ctrl,i", description: "To create issue" },
{ keys: "ctrl,q", description: "To create cycle" },
{ keys: "ctrl,m", description: "To create module" },
{ keys: "ctrl,d", description: "To bulk delete issues" },
{ keys: "ctrl,h", description: "To open shortcuts guide" },
{
keys: "ctrl,alt,c",

View File

@ -22,6 +22,7 @@ import {
import { PRIORITIES } from "constants/";
import useUser from "lib/hooks/useUser";
import React from "react";
import { getPriorityIcon } from "constants/global";
type Props = {
issue: IIssue;
@ -95,7 +96,7 @@ const SingleIssue: React.FC<Props> = ({
<>
<div>
<Listbox.Button
className={`rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
className={`grid place-items-center rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
@ -107,7 +108,7 @@ const SingleIssue: React.FC<Props> = ({
: "bg-gray-100"
}`}
>
{issue.priority ?? "None"}
{getPriorityIcon(issue?.priority ?? "None")}
</Listbox.Button>
<Transition
@ -124,18 +125,19 @@ const SingleIssue: React.FC<Props> = ({
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer capitalize select-none px-3 py-2"
"flex items-center gap-2 cursor-pointer capitalize select-none px-3 py-2"
)
}
value={priority}
>
{getPriorityIcon(priority)}
{priority}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
@ -152,7 +154,7 @@ const SingleIssue: React.FC<Props> = ({
>
{issue.priority ?? "None"}
</div>
</div>
</div> */}
</>
)}
</Listbox>
@ -210,10 +212,10 @@ const SingleIssue: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue.state_detail.name}</div>
</div>
</div> */}
</>
)}
</Listbox>
@ -222,10 +224,10 @@ const SingleIssue: React.FC<Props> = ({
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date ? renderShortNumericDateFormat(issue.start_date) : "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>{renderShortNumericDateFormat(issue.start_date ?? "")}</div>
</div>
</div> */}
</div>
)}
{properties.due_date && (
@ -240,7 +242,7 @@ const SingleIssue: React.FC<Props> = ({
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Target date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
@ -251,7 +253,7 @@ const SingleIssue: React.FC<Props> = ({
? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days`
: "Due date")}
</div>
</div>
</div> */}
</div>
)}
{properties.assignee && (
@ -373,14 +375,14 @@ const SingleIssue: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details.map((assignee) => assignee.first_name).join(", ")
: "No one"}
</div>
</div>
</div> */}
</>
)}
</Listbox>

View File

@ -0,0 +1,230 @@
// react
import React, { useState } from "react";
// swr
import { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// common
import { classNames } from "constants/common";
type FormInput = {
issue_ids: string[];
cycleId: string;
};
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const { activeWorkspace, activeProject, issues } = useUser();
const { setToastAlert } = useToast();
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const { register, handleSubmit, reset } = useForm<FormInput>();
const handleClose = () => {
setIsOpen(false);
setQuery("");
reset();
};
const handleDelete: SubmitHandler<FormInput> = (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data)
.then((res) => {
setToastAlert({
title: "Success",
type: "success",
message: res.message,
});
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
count: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
).length,
results: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
),
};
},
false
);
})
.catch((e) => {
console.log(e);
});
}
};
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<form>
<Combobox>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</>
)}
</Combobox.Option>
))}
</ul>
</li>
</>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<FolderIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
<div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected issues
</Button>
<div>
<Button type="button" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default BulkDeleteIssuesModal;

View File

@ -2,44 +2,42 @@
import React, { useState } from "react";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
// constants
// common
import { classNames } from "constants/common";
import { mutate } from "swr";
import { CYCLE_ISSUES } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
issues: IssueResponse | undefined;
cycleId: string;
};
type FormInput = {
issues: string[];
};
const CycleIssuesListModal: React.FC<Props> = ({
type Props = {
isOpen: boolean;
handleClose: () => void;
type: string;
issues: IIssue[];
handleOnSubmit: (data: FormInput) => void;
};
const ExistingIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
issues,
cycleId,
handleOnSubmit,
type,
}) => {
const [query, setQuery] = useState("");
const { activeWorkspace, activeProject } = useUser();
const { activeProject } = useUser();
const { setToastAlert } = useToast();
@ -60,7 +58,7 @@ const CycleIssuesListModal: React.FC<Props> = ({
},
});
const handleAddToCycle: SubmitHandler<FormInput> = (data) => {
const onSubmit: SubmitHandler<FormInput> = (data) => {
if (!data.issues || data.issues.length === 0) {
setToastAlert({
title: "Error",
@ -70,25 +68,14 @@ const CycleIssuesListModal: React.FC<Props> = ({
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, data)
.then((res) => {
console.log(res);
mutate(CYCLE_ISSUES(cycleId));
handleOnSubmit(data);
handleClose();
})
.catch((e) => {
console.log(e);
});
}
};
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
? issues ?? []
: issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
return (
<>
@ -143,12 +130,12 @@ const CycleIssuesListModal: React.FC<Props> = ({
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues to add to cycle
Select issues to add to {type}
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (!issue.issue_cycle)
if ((type === "cycle" && !issue.issue_cycle) || type === "module")
return (
<Combobox.Option
key={issue.id}
@ -206,10 +193,10 @@ const CycleIssuesListModal: React.FC<Props> = ({
<Button
type="button"
size="sm"
onClick={handleSubmit(handleAddToCycle)}
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
{isSubmitting ? "Adding..." : "Add to Cycle"}
{isSubmitting ? "Adding..." : `Add to ${type}`}
</Button>
</div>
</form>
@ -222,4 +209,4 @@ const CycleIssuesListModal: React.FC<Props> = ({
);
};
export default CycleIssuesListModal;
export default ExistingIssuesListModal;

View File

@ -8,8 +8,6 @@ import workspaceService from "lib/services/workspace.service";
import useUser from "lib/hooks/useUser";
// components
import SingleIssue from "components/common/board-view/single-issue";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// ui
import { CustomMenu } from "ui";
// icons
@ -19,7 +17,7 @@ import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// common
import { addSpaceIfCamelCase, classNames } from "constants/common";
import { addSpaceIfCamelCase } from "constants/common";
type Props = {
properties: Properties;
@ -46,7 +44,7 @@ type Props = {
stateId: string | null;
};
const SingleCycleBoard: React.FC<Props> = ({
const SingleModuleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
@ -207,4 +205,4 @@ const SingleCycleBoard: React.FC<Props> = ({
);
};
export default SingleCycleBoard;
export default SingleModuleBoard;

View File

@ -35,6 +35,7 @@ type Props = {
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
@ -52,6 +53,7 @@ const CyclesListView: React.FC<Props> = ({
openIssuesListModal,
properties,
removeIssueFromCycle,
handleDeleteIssue,
setPreloadedData,
}) => {
const { activeWorkspace, activeProject, states } = useUser();
@ -264,7 +266,11 @@ const CyclesListView: React.FC<Props> = ({
>
Remove from cycle
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>Delete permanently</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => handleDeleteIssue(issue.id)}
>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>

View File

@ -115,7 +115,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
}, 500);
};
const addIssueToCycle = async (issueId: string, cycleId: string, issueDetail: IIssue) => {
const addIssueToCycle = async (issueId: string, cycleId: string) => {
if (!activeWorkspace || !activeProject) return;
await issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
@ -169,7 +169,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
mutate<IssueResponse>(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id));
if (formData.sprints && formData.sprints !== null) {
await addIssueToCycle(res.id, formData.sprints, formData);
await addIssueToCycle(res.id, formData.sprints);
}
handleClose();
resetForm();
@ -209,7 +209,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
false
);
if (formData.sprints && formData.sprints !== null) {
await addIssueToCycle(res.id, formData.sprints, formData);
await addIssueToCycle(res.id, formData.sprints);
}
handleClose();
resetForm();

View File

@ -51,7 +51,7 @@ const SelectProject: React.FC<Props> = ({ control }) => {
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
<div className="py-1">
{projects ? (
projects.length > 0 ? (
projects.map((project) => (
@ -59,8 +59,8 @@ const SelectProject: React.FC<Props> = ({ control }) => {
key={project.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none p-2 rounded-md`
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-pointer select-none p-2`
}
value={project.id}
>

View File

@ -15,7 +15,7 @@ import { Listbox, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import { ArrowPathIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import { UserGroupIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import { IIssue } from "types";
@ -39,7 +39,7 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ArrowPathIcon className="flex-shrink-0 h-4 w-4" />
<UserGroupIcon className="flex-shrink-0 h-4 w-4" />
<p>Assignees</p>
</div>
<div className="sm:basis-1/2">
@ -128,7 +128,7 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options className="absolute z-10 right-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<Listbox.Options className="absolute z-10 left-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="py-1">
{people ? (
people.length > 0 ? (

View File

@ -79,7 +79,7 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
});
});
// handleClose();
handleClose();
};
const removeBlocked = (issueId: string) => {

View File

@ -0,0 +1,96 @@
// components
import SingleBoard from "components/project/modules/board-view/single-board";
// ui
import { Spinner } from "ui";
// types
import { IIssue, IProjectMember, NestedKeyOf, Properties } from "types";
import useUser from "lib/hooks/useUser";
type Props = {
groupedByIssues: {
[key: string]: IIssue[];
};
properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromModule: (issueId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
};
const ModulesBoardView: React.FC<Props> = ({
groupedByIssues,
properties,
selectedGroup,
members,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromModule,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
}) => {
const { states } = useUser();
return (
<>
{groupedByIssues ? (
<div className="h-full w-full">
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex gap-x-4 h-full overflow-x-auto overflow-y-hidden pb-3">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: undefined
}
properties={properties}
removeIssueFromModule={removeIssueFromModule}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
/>
))}
</div>
</div>
</div>
</div>
) : (
<div className="h-full w-full flex justify-center items-center">
<Spinner />
</div>
)}
</>
);
};
export default ModulesBoardView;

View File

@ -0,0 +1,208 @@
// react
import React, { useState } from "react";
// swr
import useSWR from "swr";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// components
import SingleIssue from "components/common/board-view/single-issue";
// ui
import { CustomMenu } from "ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// common
import { addSpaceIfCamelCase } from "constants/common";
type Props = {
properties: Properties;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor?: string;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromModule: (bridgeId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
stateId: string | null;
};
const SingleCycleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromModule,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
stateId,
}) => {
// Collapse/Expand
const [show, setState] = useState(true);
const { activeWorkspace } = useUser();
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
}`}
>
<div
className={`w-full flex justify-between items-center ${
!show ? "flex-col gap-2" : "gap-1"
}`}
>
<div
className={`flex items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
!show ? "hidden" : "block"
}`}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<SingleIssue
key={childIssue.id}
issue={childIssue}
properties={properties}
assignees={assignees}
people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
/>
);
})}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
withoutBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
);
};
export default SingleCycleBoard;

View File

@ -0,0 +1,147 @@
// react
import React, { useEffect, useRef, useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IModule;
};
const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const router = useRouter();
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!activeWorkspace || !data) return;
await modulesService
.deleteModule(activeWorkspace.slug, data.project, data.id)
.then(() => {
mutate(MODULE_LIST(data.project));
router.push(`/projects/${data.project}/modules`);
handleClose();
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={handleClose}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete Module
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete module - {`"`}
<span className="italic">{data?.name}</span>
{`?"`} All of the data related to the module will be permanently removed.
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={handleClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ConfirmModuleDeletion;

View File

@ -17,6 +17,9 @@ import type { IModule } from "types";
import { renderDateFormat } from "constants/common";
// fetch keys
import { MODULE_LIST } from "constants/fetch-keys";
import SelectLead from "./select-lead";
import SelectMembers from "./select-members";
import SelectStatus from "./select-status";
type Props = {
isOpen: boolean;
@ -28,6 +31,9 @@ type Props = {
const defaultValues: Partial<IModule> = {
name: "",
description: "",
status: null,
lead: null,
members_list: [],
};
const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
@ -45,6 +51,7 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
setError,
} = useForm<IModule>({
@ -140,7 +147,7 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<Dialog.Panel className="relative transform rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
@ -172,26 +179,6 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
register={register}
/>
</div>
<div>
<Select
id="status"
name="status"
label="Status"
error={errors.status}
register={register}
validations={{
required: "Status is required",
}}
options={[
{ label: "Backlog", value: "backlog" },
{ label: "Planned", value: "planned" },
{ label: "In Progress", value: "in-progress" },
{ label: "Paused", value: "paused" },
{ label: "Completed", value: "completed" },
{ label: "Cancelled", value: "cancelled" },
]}
/>
</div>
<div className="flex gap-x-2">
<div className="w-full">
<Input
@ -216,6 +203,11 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
/>
</div>
</div>
<div className="flex items-center flex-wrap gap-2">
<SelectStatus control={control} />
<SelectLead control={control} />
<SelectMembers control={control} />
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">

View File

@ -0,0 +1,61 @@
// react
import React from "react";
// swr
import useSWR from "swr";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { SearchListbox } from "ui";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IModule, any>;
};
const SelectLead: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)
: null
);
return (
<Controller
control={control}
name="lead"
render={({ field: { value, onChange } }) => (
<SearchListbox
title="Lead"
optionsFontsize="sm"
options={people?.map((person) => {
return {
value: person.member.id,
display:
person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email,
};
})}
value={value}
onChange={onChange}
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
/>
)}
/>
);
};
export default SelectLead;

View File

@ -0,0 +1,62 @@
// react
import React from "react";
// swr
import useSWR from "swr";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { SearchListbox } from "ui";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IModule, any>;
};
const SelectMembers: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)
: null
);
return (
<Controller
control={control}
name="members_list"
render={({ field: { value, onChange } }) => (
<SearchListbox
title="Members"
optionsFontsize="sm"
options={people?.map((person) => {
return {
value: person.member.id,
display:
person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email,
};
})}
multiple={true}
value={value}
onChange={onChange}
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
/>
)}
/>
);
};
export default SelectMembers;

View File

@ -0,0 +1,39 @@
// react
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// ui
import { CustomListbox } from "ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
import { MODULE_STATUS } from "constants/";
type Props = {
control: Control<IModule, any>;
};
const SelectStatus: React.FC<Props> = ({ control }) => {
return (
<Controller
control={control}
name="status"
render={({ field: { value, onChange } }) => (
<CustomListbox
title="State"
options={MODULE_STATUS.map((status) => {
return { value: status.value, display: status.label };
})}
value={value}
optionsFontsize="sm"
onChange={onChange}
icon={<Squares2X2Icon className="h-3 w-3 text-gray-400" />}
/>
)}
/>
);
};
export default SelectStatus;

View File

@ -0,0 +1,328 @@
// react
import React from "react";
// next
import Link from "next/link";
// swr
import useSWR from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { CustomMenu, Spinner } from "ui";
// icons
import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import workspaceService from "lib/services/workspace.service";
type Props = {
groupedByIssues: {
[key: string]: (IIssue & { bridge?: string })[];
};
properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromModule: (issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
};
const ModulesListView: React.FC<Props> = ({
groupedByIssues,
selectedGroup,
openCreateIssueModal,
openIssuesListModal,
properties,
removeIssueFromModule,
handleDeleteIssue,
setPreloadedData,
}) => {
const { activeWorkspace, activeProject, states } = useUser();
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
return (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="bg-white rounded-lg">
<div className="bg-gray-100 px-4 py-3 rounded-t-lg">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium leading-5 capitalize">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-gray-500 text-sm">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="divide-y-2">
{groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find(
(p) => p.member.id === assignee
)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<div
key={issue.id}
className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/projects/${activeProject?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div> */}
</a>
</Link>
</div>
<div className="flex-shrink-0 flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group relative flex-shrink-0 flex items-center gap-1 text-xs rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(issue.priority ?? "")} */}
{issue.priority ?? "None"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
></span>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date
? renderShortNumericDateFormat(issue.start_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(issue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.due_date && (
<div
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 &&
"text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Due date</h5>
<div>
{renderShortNumericDateFormat(issue.target_date ?? "")}
</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Due date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Due date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Due date")}
</div>
</div>
</div>
)}
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem
onClick={() => openCreateIssueModal(issue, "edit")}
>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => removeIssueFromModule(issue.bridge ?? "")}
>
Remove from module
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => handleDeleteIssue(issue.id)}
>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
);
})
) : (
<p className="text-sm px-4 py-3 text-gray-500">No issues.</p>
)
) : (
<div className="h-full w-full flex items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
optionsPosition="left"
withoutBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: singleGroup,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
)}
</Disclosure>
);
})}
</div>
);
};
export default ModulesListView;

View File

@ -0,0 +1,246 @@
// react
import { useEffect } from "react";
// swr
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// components
import SelectMembers from "components/project/modules/module-detail-sidebar/select-members";
import SelectStatus from "components/project/modules/module-detail-sidebar/select-status";
// ui
import { Spinner } from "ui";
// icons
import {
CalendarDaysIcon,
ClipboardDocumentIcon,
LinkIcon,
PlusIcon,
TrashIcon,
UserIcon,
} from "@heroicons/react/24/outline";
// types
import { IModule } from "types";
// fetch-keys
import { MODULE_DETAIL } from "constants/fetch-keys";
// common
import { copyTextToClipboard } from "constants/common";
const defaultValues: Partial<IModule> = {
members_list: [],
start_date: new Date().toString(),
target_date: new Date().toString(),
status: null,
};
type Props = {
module?: IModule;
isOpen: boolean;
handleDeleteModule: () => void;
};
const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModule }) => {
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const { reset, watch, control } = useForm({
defaultValues,
});
const submitChanges = (data: Partial<IModule>) => {
if (!activeWorkspace || !activeProject || !module) return;
modulesService
.patchModule(activeWorkspace.slug, activeProject.id, module.id, data)
.then((res) => {
console.log(res);
mutate(MODULE_DETAIL);
})
.catch((e) => {
console.log(e);
});
};
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((member) => member.id),
});
}, [module, reset]);
return (
<>
<div
className={`fixed top-0 ${
isOpen ? "right-0" : "-right-[24rem]"
} z-30 bg-gray-50 border-l h-full p-5 w-[24rem] overflow-y-auto duration-300`}
>
{module ? (
<>
<div className="flex justify-between items-center pb-3">
<h4 className="text-sm font-medium">{module.name}</h4>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${activeProject?.id}/modules/${module.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(module.id)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-red-50 text-red-500 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() => handleDeleteModule()}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1">
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="flex-shrink-0 h-4 w-4" />
<p>Lead</p>
</div>
<div className="sm:basis-1/2">
{module.lead_detail.first_name !== "" ? (
<>
{module.lead_detail.first_name} {module.lead_detail.last_name}
</>
) : (
module.lead_detail.email
)}
</div>
</div>
<SelectMembers control={control} submitChanges={submitChanges} />
</div>
<div className="py-1">
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="flex-shrink-0 h-4 w-4" />
<p>Start date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
id="issueDate"
value={value ?? ""}
onChange={onChange}
className="hover:bg-gray-100 bg-transparent border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
/>
)}
/>
</div>
</div>
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="flex-shrink-0 h-4 w-4" />
<p>End date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
submitChanges({ target_date: e.target.value });
onChange(e.target.value);
}}
className="hover:bg-gray-100 bg-transparent border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
/>
)}
/>
</div>
</div>
</div>
<div className="py-1">
<SelectStatus control={control} submitChanges={submitChanges} watch={watch} />
</div>
<div className="py-1">
<div className="flex justify-between items-center gap-2">
<h4>Links</h4>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-2 space-y-2">
<div className="flex gap-2 border bg-gray-100 rounded-md p-2">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5>Aaryan Khandelwal</h5>
<p className="text-gray-500 mt-0.5">
Added 2 days ago by aaryan.khandelwal@caravel.tech
</p>
</div>
</div>
</div>
</div>
</div>
</>
) : (
<div className="h-full w-full flex justify-center items-center">
<Spinner />
</div>
)}
</div>
</>
);
};
export default ModuleDetailSidebar;

View File

@ -0,0 +1,188 @@
// react
import React from "react";
// next
import Image from "next/image";
// swr
import useSWR from "swr";
// react-hook-form
import { Control, Controller } from "react-hook-form";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import { UserGroupIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import { IModule } from "types";
// constants
import { classNames } from "constants/common";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<Partial<IModule>, any>;
submitChanges: (formData: Partial<IModule>) => void;
};
const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
const { activeWorkspace } = useUser();
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="flex-shrink-0 h-4 w-4" />
<p>Assignees</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="members_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={true}
onChange={(value: any) => {
submitChanges({ members_list: value });
}}
className="flex-shrink-0"
>
{({ open }) => (
<div className="relative">
<Listbox.Button className="w-full flex items-center gap-1 text-xs cursor-pointer">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left"
)}
>
<div className="flex items-center gap-1 text-xs cursor-pointer">
{value && Array.isArray(value) ? (
<>
{value.length > 0 ? (
value.map((assignee, index: number) => {
const person = people?.find(
(p) => p.member.id === assignee
)?.member;
return (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{person && person.avatar && person.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={person.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={person.first_name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize`}
>
{person?.first_name && person.first_name !== ""
? person.first_name.charAt(0)
: person?.email.charAt(0)}
</div>
)}
</div>
);
})
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</>
) : null}
</div>
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options className="absolute z-10 left-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="py-1">
{people ? (
people.length > 0 ? (
people.map((option) => (
<Listbox.Option
key={option.member.id}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex items-center gap-2 text-gray-900 cursor-pointer select-none p-2 truncate`
}
value={option.member.id}
>
{option.member.avatar && option.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={option.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
) : (
<div className="flex-shrink-0 h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name.charAt(0)
: option.member.email.charAt(0)}
</div>
)}
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name
: option.member.email}
</Listbox.Option>
))
) : (
<div className="text-center">No assignees found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
</div>
</div>
);
};
export default SelectMembers;

View File

@ -0,0 +1,63 @@
// react
import React from "react";
// react-hook-form
import { Control, Controller, UseFormWatch } from "react-hook-form";
// ui
import { CustomSelect } from "ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
// types
import { IModule } from "types";
// common
import { classNames } from "constants/common";
// constants
import { MODULE_STATUS } from "constants/";
type Props = {
control: Control<Partial<IModule>, any>;
submitChanges: (formData: Partial<IModule>) => void;
watch: UseFormWatch<Partial<IModule>>;
};
const SelectStatus: React.FC<Props> = ({ control, submitChanges, watch }) => {
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<Squares2X2Icon className="flex-shrink-0 h-4 w-4" />
<p>Status</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="status"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={classNames(
value ? "" : "text-gray-900",
"text-left capitalize flex items-center gap-2"
)}
>
{watch("status")}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
}}
>
{MODULE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
{option.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
);
};
export default SelectStatus;

View File

@ -0,0 +1,102 @@
// next
import Image from "next/image";
import Link from "next/link";
// icons
import User from "public/user.png";
// types
import { IModule } from "types";
// common
import { renderShortNumericDateFormat } from "constants/common";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
type Props = {
module: IModule;
};
const SingleModuleCard: React.FC<Props> = ({ module }) => {
return (
<div key={module.id} className="border bg-white p-3 rounded-md">
<Link href={`/projects/${module.project}/modules/${module.id}`}>
<a>{module.name}</a>
</Link>
<div className="grid grid-cols-4 gap-2 text-xs mt-4">
<div className="space-y-2">
<h6 className="text-gray-500">LEAD</h6>
<div>
{module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? (
<div className="h-5 w-5 border-2 border-white rounded-full">
<Image
src={module.lead_detail.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={module.lead_detail.first_name}
/>
</div>
) : (
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
{module.lead_detail?.first_name && module.lead_detail.first_name !== ""
? module.lead_detail.first_name.charAt(0)
: module.lead_detail?.email.charAt(0)}
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">MEMBERS</h6>
<div className="flex items-center gap-1 text-xs">
{module.members && module.members.length > 0 ? (
module?.members_detail?.map((member, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${index !== 0 ? "-ml-2.5" : ""}`}
>
{member?.avatar && member.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={member.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={member?.first_name}
/>
</div>
) : (
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
{member?.first_name && member.first_name !== ""
? member.first_name.charAt(0)
: member?.email?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">END DATE</h6>
<div className="flex items-center gap-1 border rounded shadow-sm px-1.5 py-0.5 cursor-pointer text-xs w-min whitespace-nowrap">
<CalendarDaysIcon className="h-3 w-3" />
{renderShortNumericDateFormat(module.target_date ?? "")}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="capitalize">{module.status}</div>
</div>
</div>
</div>
);
};
export default SingleModuleCard;

View File

@ -141,6 +141,6 @@ export const MODULE_ISSUE_DETAIL = (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
bridgeId: string
) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${issueId}/`;
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${bridgeId}/`;

View File

@ -1,4 +1,6 @@
export const getPriorityIcon = (priority: string, className: string) => {
export const getPriorityIcon = (priority: string, className?: string) => {
if (!className || className === "") className = "text-xs";
switch (priority) {
case "urgent":
return <span className={`material-symbols-rounded ${className}`}>error</span>;
@ -13,6 +15,6 @@ export const getPriorityIcon = (priority: string, className: string) => {
<span className={`material-symbols-rounded ${className}`}>signal_cellular_alt_1_bar</span>
);
default:
return null;
return "None";
}
};

View File

@ -19,6 +19,15 @@ export const GROUP_CHOICES = {
cancelled: "Cancelled",
};
export const MODULE_STATUS = [
{ label: "Backlog", value: "backlog" },
{ label: "Planned", value: "planned" },
{ label: "In Progress", value: "in-progress" },
{ label: "Paused", value: "paused" },
{ label: "Completed", value: "completed" },
{ label: "Cancelled", value: "cancelled" },
];
export const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },

View File

@ -19,11 +19,13 @@ import {
PROJECT_ISSUES_LIST,
STATE_LIST,
CYCLE_LIST,
MODULE_LIST,
} from "constants/fetch-keys";
// types
import type { KeyedMutator } from "swr";
import type { IUser, IWorkspace, IProject, IssueResponse, ICycle, IState } from "types";
import type { IUser, IWorkspace, IProject, IssueResponse, ICycle, IState, IModule } from "types";
import modulesService from "lib/services/modules.service";
interface IUserContextProps {
user?: IUser;
@ -40,6 +42,8 @@ interface IUserContextProps {
mutateIssues: KeyedMutator<IssueResponse>;
cycles?: ICycle[];
mutateCycles: KeyedMutator<ICycle[]>;
modules?: IModule[];
mutateModules: KeyedMutator<IModule[]>;
states?: IState[];
mutateStates: KeyedMutator<IState[]>;
}
@ -99,6 +103,13 @@ export const UserProvider = ({ children }: { children: ReactElement }) => {
: null
);
const { data: modules, mutate: mutateModules } = useSWR<IModule[]>(
activeWorkspace && activeProject ? MODULE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => modulesService.getModules(activeWorkspace.slug, activeProject.id)
: null
);
useEffect(() => {
if (!projects) return;
const activeProject = projects.find((project) => project.id === projectId);
@ -143,6 +154,8 @@ export const UserProvider = ({ children }: { children: ReactElement }) => {
mutateIssues,
cycles,
mutateCycles,
modules,
mutateModules,
states,
mutateStates,
setActiveProject,

View File

@ -50,6 +50,16 @@ class ProjectIssuesServices extends APIService {
});
}
async getModuleDetails(workspaceSlug: string, projectId: string, moduleId: string): Promise<any> {
return this.get(MODULE_DETAIL(workspaceSlug, projectId, moduleId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async patchModule(
workspaceSlug: string,
projectId: string,
@ -85,11 +95,11 @@ class ProjectIssuesServices extends APIService {
});
}
async addIssueToModule(
async addIssuesToModule(
workspaceSlug: string,
projectId: string,
moduleId: string,
data: any
data: { issues: string[] }
): Promise<any> {
return this.post(MODULE_ISSUES(workspaceSlug, projectId, moduleId), data)
.then((response) => {
@ -104,9 +114,9 @@ class ProjectIssuesServices extends APIService {
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
bridgeId: string
): Promise<any> {
return this.delete(MODULE_ISSUE_DETAIL(workspaceSlug, projectId, moduleId, issueId))
return this.delete(MODULE_ISSUE_DETAIL(workspaceSlug, projectId, moduleId, bridgeId))
.then((response) => {
return response?.data;
})

View File

@ -243,11 +243,11 @@ const MyIssues: NextPage = () => {
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
)} */}
<span className="">{issue.name}</span>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<span>{issue.name}</span>
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div>
</div> */}
</a>
</Link>
</div>

View File

@ -12,8 +12,8 @@ import AppLayout from "layouts/app-layout";
import CyclesListView from "components/project/cycles/list-view";
import CyclesBoardView from "components/project/cycles/board-view";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CycleIssuesListModal from "components/project/cycles/cycle-issues-list-modal";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import ExistingIssuesListModal from "components/common/existing-issues-list-modal";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
// services
@ -67,7 +67,7 @@ const SingleCycle: React.FC = () => {
: null
);
const cycleIssuesArray = cycleIssues?.map((issue) => {
return { bridge: issue.id, ...issue.issue_details };
return { bridge: issue.id, ...issue.issue_detail };
});
const { data: members } = useSWR(
@ -163,6 +163,20 @@ const SingleCycle: React.FC = () => {
// console.log(result);
};
const handleAddIssuesToCycle = (data: { issues: string[] }) => {
if (activeWorkspace && activeProject) {
issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId as string, data)
.then((res) => {
console.log(res);
mutate(CYCLE_ISSUES(cycleId as string));
})
.catch((e) => {
console.log(e);
});
}
};
const removeIssueFromCycle = (bridgeId: string) => {
if (activeWorkspace && activeProject) {
mutate<CycleIssueResponse[]>(
@ -191,11 +205,12 @@ const SingleCycle: React.FC = () => {
setIsOpen={setIsIssueModalOpen}
projectId={activeProject?.id}
/>
<CycleIssuesListModal
<ExistingIssuesListModal
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
issues={issues}
cycleId={cycleId as string}
type="cycle"
issues={issues?.results ?? []}
handleOnSubmit={handleAddIssuesToCycle}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
@ -375,10 +390,10 @@ const SingleCycle: React.FC = () => {
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
removeIssueFromCycle={removeIssueFromCycle}
handleDeleteIssue={setDeleteIssue}
setPreloadedData={setPreloadedData}
/>
) : (
<div className="h-screen">
<CyclesBoardView
groupedByIssues={groupedByIssues}
properties={properties}
@ -391,7 +406,6 @@ const SingleCycle: React.FC = () => {
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData}
/>
</div>
)}
</AppLayout>
</>

View File

@ -334,6 +334,7 @@ const ProjectIssues: NextPage = () => {
/>
</div>
)}
<div></div>
</>
) : (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">

View File

@ -0,0 +1,407 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// services
import modulesService from "lib/services/modules.service";
import projectService from "lib/services/project.service";
import issuesService from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useIssuesFilter from "lib/hooks/useIssuesFilter";
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// layouts
import AppLayout from "layouts/app-layout";
// components
import ExistingIssuesListModal from "components/common/existing-issues-list-modal";
import ModulesBoardView from "components/project/modules/board-view";
import ModulesListView from "components/project/modules/list-view";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import ModuleDetailSidebar from "components/project/modules/module-detail-sidebar";
import ConfirmModuleDeletion from "components/project/modules/confirm-module-deleteion";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { BreadcrumbItem, Breadcrumbs, CustomMenu } from "ui";
// icons
import {
ArrowLeftIcon,
ArrowPathIcon,
ChevronDownIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
import { Squares2X2Icon } from "@heroicons/react/20/solid";
// types
import { IIssue, IModule, ModuleIssueResponse, Properties, SelectModuleType } from "types";
// fetch-keys
import { MODULE_DETAIL, MODULE_ISSUES, PROJECT_MEMBERS } from "constants/fetch-keys";
// common
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
const SingleModule = () => {
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
const [moduleSidebar, setModuleSidebar] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [selectedModuleForDelete, setSelectedModuleForDelete] = useState<SelectModuleType>();
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
const { activeWorkspace, activeProject, issues, modules } = useUser();
const router = useRouter();
const { moduleId } = router.query;
const [properties, setProperties] = useIssuesProperties(
activeWorkspace?.slug,
activeProject?.id as string
);
const { data: moduleIssues } = useSWR<ModuleIssueResponse[]>(
activeWorkspace && activeProject && moduleId ? MODULE_ISSUES(moduleId as string) : null,
activeWorkspace && activeProject && moduleId
? () =>
modulesService.getModuleIssues(
activeWorkspace?.slug,
activeProject?.id,
moduleId as string
)
: null
);
const moduleIssuesArray = moduleIssues?.map((issue) => {
return { bridge: issue.id, ...issue.issue_detail };
});
const { data: moduleDetail } = useSWR<IModule>(
MODULE_DETAIL,
activeWorkspace && activeProject && moduleId
? () =>
modulesService.getModuleDetails(
activeWorkspace?.slug,
activeProject?.id,
moduleId as string
)
: null
);
const {
issueView,
groupByProperty,
setGroupByProperty,
groupedByIssues,
setOrderBy,
setFilterIssue,
orderBy,
filterIssue,
setIssueViewToKanban,
setIssueViewToList,
} = useIssuesFilter(moduleIssuesArray ?? []);
const { data: members } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id)
: null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
);
const handleAddIssuesToModule = (data: { issues: string[] }) => {
if (activeWorkspace && activeProject) {
modulesService
.addIssuesToModule(activeWorkspace.slug, activeProject.id, moduleId as string, data)
.then((res) => {
console.log(res);
})
.catch((e) => console.log(e));
}
};
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!activeWorkspace || !activeProject) return;
issuesService
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, formData)
.then((response) => {
mutate(MODULE_ISSUES(moduleId as string));
})
.catch((error) => {
console.log(error);
});
};
const openCreateIssueModal = () => {};
const openIssuesListModal = () => {
setModuleIssuesListModal(true);
};
const removeIssueFromModule = (issueId: string) => {
if (!activeWorkspace || !activeProject) return;
modulesService
.removeIssueFromModule(activeWorkspace.slug, activeProject.id, moduleId as string, issueId)
.then((res) => {
console.log(res);
mutate(MODULE_ISSUES(moduleId as string));
})
.catch((e) => {
console.log(e);
});
};
const handleDeleteModule = () => {
if (!moduleDetail) return;
setSelectedModuleForDelete({ ...moduleDetail, actionType: "delete" });
setModuleDeleteModal(true);
};
return (
<>
<ExistingIssuesListModal
isOpen={moduleIssuesListModal}
handleClose={() => setModuleIssuesListModal(false)}
type="module"
issues={issues?.results ?? []}
handleOnSubmit={handleAddIssuesToModule}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={issues?.results.find((issue) => issue.id === deleteIssue)}
/>
<ConfirmModuleDeletion
isOpen={
moduleDeleteModal &&
!!selectedModuleForDelete &&
selectedModuleForDelete.actionType === "delete"
}
setIsOpen={setModuleDeleteModal}
data={selectedModuleForDelete}
/>
<AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Modules`}
link={`/projects/${activeProject?.id}/cycles`}
/>
</Breadcrumbs>
}
left={
<CustomMenu
label={
<>
<ArrowPathIcon className="h-3 w-3" />
{modules?.find((c) => c.id === moduleId)?.name}
</>
}
className="ml-1.5"
width="auto"
>
{modules?.map((module) => (
<CustomMenu.MenuItem
key={module.id}
renderAs="a"
href={`/projects/${activeProject?.id}/modules/${module.id}`}
>
{module.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
}
right={
<div
className={`flex items-center gap-2 ${moduleSidebar ? "mr-[24rem]" : ""} duration-300`}
>
<div className="flex items-center gap-x-1">
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none ${
issueView === "list" ? "bg-gray-100" : ""
}`}
onClick={() => setIssueViewToList()}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none ${
issueView === "kanban" ? "bg-gray-100" : ""
}`}
onClick={() => setIssueViewToKanban()}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2"
)}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform p-3 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
width="auto"
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="auto"
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)
?.name ?? "Select"
}
width="auto"
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div className="flex items-center gap-2 flex-wrap">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 capitalize rounded border border-theme text-xs ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none ${
moduleSidebar ? "rotate-180" : ""
}`}
onClick={() => setModuleSidebar((prevData) => !prevData)}
>
<ArrowLeftIcon className="h-4 w-4" />
</button>
</div>
}
>
<div className={`h-full ${moduleSidebar ? "mr-[28rem]" : ""} duration-300`}>
{issueView === "list" ? (
<ModulesListView
groupedByIssues={groupedByIssues}
selectedGroup={groupByProperty}
properties={properties}
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
removeIssueFromModule={removeIssueFromModule}
handleDeleteIssue={setDeleteIssue}
setPreloadedData={setPreloadedData}
/>
) : (
<ModulesBoardView
groupedByIssues={groupedByIssues}
properties={properties}
removeIssueFromModule={removeIssueFromModule}
selectedGroup={groupByProperty}
members={members}
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData}
/>
)}
</div>
<ModuleDetailSidebar
module={moduleDetail}
isOpen={moduleSidebar}
handleDeleteModule={handleDeleteModule}
/>
</AppLayout>
</>
);
};
export default SingleModule;

View File

@ -10,6 +10,8 @@ import withAuth from "lib/hoc/withAuthWrapper";
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// components
import SingleModuleCard from "components/project/modules/single-module-card";
// ui
import { BreadcrumbItem, Breadcrumbs, EmptySpace, EmptySpaceItem, HeaderButton, Spinner } from "ui";
// icons
@ -17,7 +19,7 @@ import { PlusIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
// types
import { IModule } from "types/modules";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
import { MODULE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
const ProjectModules: NextPage = () => {
const { activeWorkspace, activeProject } = useUser();
@ -62,13 +64,12 @@ const ProjectModules: NextPage = () => {
{modules ? (
modules.length > 0 ? (
<div className="space-y-5">
<div className="grid grid-cols-3 gap-2">
{modules.map((module) => (
<div key={module.id} className="bg-white p-3 rounded-md">
<h3>{module.name}</h3>
<p className="text-gray-500 text-sm mt-2">{module.description}</p>
</div>
<SingleModuleCard key={module.id} module={module} />
))}
</div>
</div>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">
<EmptySpace

View File

@ -119,7 +119,8 @@ const ControlSettings = () => {
<h3 className="text-3xl font-bold leading-6 text-gray-900">Control</h3>
<p className="mt-4 text-sm text-gray-500">Set the control for the project.</p>
</div>
<div className="grid grid-cols-2 gap-16">
<div className="grid grid-cols-12 gap-16">
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Project Lead</h4>
<p className="text-sm text-gray-500 mb-3">Select the project leader.</p>
@ -136,7 +137,10 @@ const ControlSettings = () => {
{people?.find((person) => person.member.id === value)?.member
.first_name ?? "Select Lead"}
</span>
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</Listbox.Button>
<Transition
@ -191,6 +195,8 @@ const ControlSettings = () => {
)}
/>
</div>
</div>
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Default Assignee</h4>
<p className="text-sm text-gray-500 mb-3">
@ -209,7 +215,10 @@ const ControlSettings = () => {
{people?.find((p) => p.member.id === value)?.member.first_name ??
"Select Default Assignee"}
</span>
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</Listbox.Button>
<Transition
@ -265,6 +274,7 @@ const ControlSettings = () => {
/>
</div>
</div>
</div>
<div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}

View File

@ -136,7 +136,8 @@ const GeneralSettings = () => {
This information will be displayed to every member of the project.
</p>
</div>
<div className="grid grid-cols-2 gap-16">
<div className="grid grid-cols-12 gap-16">
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Icon & Name</h4>
<p className="text-sm text-gray-500 mb-3">
@ -168,18 +169,6 @@ const GeneralSettings = () => {
/>
</div>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Description</h4>
<p className="text-sm text-gray-500 mb-3">Give a description to the project.</p>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Identifier</h4>
<p className="text-sm text-gray-500 mb-3">
@ -210,6 +199,20 @@ const GeneralSettings = () => {
}}
/>
</div>
</div>
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Description</h4>
<p className="text-sm text-gray-500 mb-3">Give a description to the project.</p>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Network</h4>
<p className="text-sm text-gray-500 mb-3">Select privacy type for the project.</p>
@ -229,6 +232,7 @@ const GeneralSettings = () => {
/>
</div>
</div>
</div>
<div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}

View File

@ -26,7 +26,7 @@ import {
UserIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import { EmptySpace, EmptySpaceItem } from "ui/empty-space";
const WorkspaceInvitation: NextPage = () => {
const router = useRouter();

View File

@ -1,26 +1,27 @@
// next
import type { NextPage } from "next";
import Link from "next/link";
// react
import React from "react";
// layouts
import AppLayout from "layouts/app-layout";
// next
import Link from "next/link";
import type { NextPage } from "next";
// swr
import useSWR from "swr";
// services
import userService from "lib/services/user.service";
// hooks
import useUser from "lib/hooks/useUser";
// hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper";
// fetch keys
import { USER_ISSUE } from "constants/fetch-keys";
// services
import userService from "lib/services/user.service";
// layouts
import AppLayout from "layouts/app-layout";
// ui
import { Spinner } from "ui";
// icons
import { ArrowRightIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import type { IIssue } from "types";
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
// common
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
@ -112,10 +113,6 @@ const Workspace: NextPage = () => {
</span>
)} */}
<span className="">{issue.name}</span>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div>
</a>
</Link>
</div>

View File

@ -19,7 +19,7 @@ export interface ICycle {
export interface CycleIssueResponse {
id: string;
issue_details: IIssue;
issue_detail: IIssue;
created_at: Date;
updated_at: Date;
created_by: string;

View File

@ -1,4 +1,4 @@
import type { IUser, IIssue, IProject } from ".";
import type { IUser, IUserLite, IIssue, IProject } from ".";
export interface IModule {
created_at: Date;
@ -9,13 +9,29 @@ export interface IModule {
id: string;
lead: string | null;
lead_detail: IUserLite;
members: string[];
members_list: string[];
members_detail: IUserLite[];
name: string;
project: string;
project_detail: IProject;
start_date: Date | null;
status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled";
target_date: Date | null;
start_date: string | null;
status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled" | null;
target_date: string | null;
updated_at: Date;
updated_by: string;
workspace: string;
}
export interface ModuleIssueResponse {
created_at: Date;
created_by: string;
id: string;
issue: string;
issue_detail: IIssue;
module: string;
module_detail: IModule;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;

View File

@ -20,7 +20,7 @@ const CustomListbox: React.FC<Props> = ({
label,
}) => {
return (
<Listbox value={value} onChange={onChange} multiple={multiple}>
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
{({ open }) => (
<>
{label && (
@ -28,9 +28,8 @@ const CustomListbox: React.FC<Props> = ({
<div className="text-gray-500 mb-2">{label}</div>
</Listbox.Label>
)}
<div className="relative">
<Listbox.Button
className={`flex items-center gap-1 hover:bg-gray-100 text-xs relative border rounded-md shadow-sm cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300 ${
className={`flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
width === "sm"
? "w-32"
: width === "md"
@ -48,7 +47,7 @@ const CustomListbox: React.FC<Props> = ({
${className || "px-2 py-1"}`}
>
{icon ?? null}
<span className="block truncate text-xs">
<span className="block truncate">
{Array.isArray(value)
? value.map((v) => options?.find((o) => o.value === v)?.display).join(", ") ||
`${title}`
@ -159,7 +158,6 @@ const CustomListbox: React.FC<Props> = ({
{footerOption ?? null}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>

View File

@ -24,7 +24,7 @@ const EmptySpace: React.FC<EmptySpaceProps> = ({ title, description, children, I
) : null}
<h2 className="text-lg font-medium text-gray-900">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
<div className="mt-1 text-sm text-gray-500">{description}</div>
<ul role="list" className="mt-6 divide-y divide-gray-200 border-t border-b border-gray-200">
{children}
</ul>
@ -80,7 +80,7 @@ const EmptySpaceItem: React.FC<EmptySpaceItemProps> = ({
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900">{title}</div>
{description ? <p className="text-sm text-gray-500">{description}</p> : null}
{description ? <div className="text-sm text-gray-500">{description}</div> : null}
</div>
<div className="flex-shrink-0 self-center">
<ChevronRightIcon

View File

@ -10,5 +10,5 @@ export { default as Tooltip } from "./Tooltip";
export { default as SearchListbox } from "./search-listbox";
export { default as HeaderButton } from "./HeaderButton";
export * from "./Breadcrumbs";
export * from "./EmptySpace";
export * from "./empty-space";
export { default as EmojiIconPicker } from "./emoji-icon-picker";

View File

@ -44,7 +44,7 @@ const SearchListbox: React.FC<Props> = ({
<>
<Combobox.Label className="sr-only">{title}</Combobox.Label>
<Combobox.Button
className={`flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300 ${
className={`flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
buttonClassName || ""
}`}
>

3
apps/docs/README.md Normal file
View File

@ -0,0 +1,3 @@
# Plane Docs
Source code that powers plane.so/docs

8
apps/docs/jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

19
apps/docs/mdx/recma.mjs Normal file
View File

@ -0,0 +1,19 @@
import { mdxAnnotations } from 'mdx-annotations'
import recmaNextjsStaticProps from 'recma-nextjs-static-props'
function recmaRemoveNamedExports() {
return (tree) => {
tree.body = tree.body.map((node) => {
if (node.type === 'ExportNamedDeclaration') {
return node.declaration
}
return node
})
}
}
export const recmaPlugins = [
mdxAnnotations.recma,
recmaRemoveNamedExports,
recmaNextjsStaticProps,
]

126
apps/docs/mdx/rehype.mjs Normal file
View File

@ -0,0 +1,126 @@
import { mdxAnnotations } from 'mdx-annotations'
import { visit } from 'unist-util-visit'
import rehypeMdxTitle from 'rehype-mdx-title'
import shiki from 'shiki'
import { toString } from 'mdast-util-to-string'
import * as acorn from 'acorn'
import { slugifyWithCounter } from '@sindresorhus/slugify'
function rehypeParseCodeBlocks() {
return (tree) => {
visit(tree, 'element', (node, _nodeIndex, parentNode) => {
if (node.tagName === 'code' && node.properties.className) {
parentNode.properties.language = node.properties.className[0]?.replace(
/^language-/,
''
)
}
})
}
}
let highlighter
function rehypeShiki() {
return async (tree) => {
highlighter =
highlighter ?? (await shiki.getHighlighter({ theme: 'css-variables' }))
visit(tree, 'element', (node) => {
if (node.tagName === 'pre' && node.children[0]?.tagName === 'code') {
let codeNode = node.children[0]
let textNode = codeNode.children[0]
node.properties.code = textNode.value
if (node.properties.language) {
let tokens = highlighter.codeToThemedTokens(
textNode.value,
node.properties.language
)
textNode.value = shiki.renderToHtml(tokens, {
elements: {
pre: ({ children }) => children,
code: ({ children }) => children,
line: ({ children }) => `<span>${children}</span>`,
},
})
}
}
})
}
}
function rehypeSlugify() {
return (tree) => {
let slugify = slugifyWithCounter()
visit(tree, 'element', (node) => {
if (node.tagName === 'h2' && !node.properties.id) {
node.properties.id = slugify(toString(node))
}
})
}
}
function rehypeAddMDXExports(getExports) {
return (tree) => {
let exports = Object.entries(getExports(tree))
for (let [name, value] of exports) {
for (let node of tree.children) {
if (
node.type === 'mdxjsEsm' &&
new RegExp(`export\\s+const\\s+${name}\\s*=`).test(node.value)
) {
return
}
}
let exportStr = `export const ${name} = ${value}`
tree.children.push({
type: 'mdxjsEsm',
value: exportStr,
data: {
estree: acorn.parse(exportStr, {
sourceType: 'module',
ecmaVersion: 'latest',
}),
},
})
}
}
}
function getSections(node) {
let sections = []
for (let child of node.children ?? []) {
if (child.type === 'element' && child.tagName === 'h2') {
sections.push(`{
title: ${JSON.stringify(toString(child))},
id: ${JSON.stringify(child.properties.id)},
...${child.properties.annotation}
}`)
} else if (child.children) {
sections.push(...getSections(child))
}
}
return sections
}
export const rehypePlugins = [
mdxAnnotations.rehype,
rehypeParseCodeBlocks,
rehypeShiki,
rehypeSlugify,
rehypeMdxTitle,
[
rehypeAddMDXExports,
(tree) => ({
sections: `[${getSections(tree).join()}]`,
}),
],
]

3
apps/docs/mdx/remark.mjs Normal file
View File

@ -0,0 +1,3 @@
import { mdxAnnotations } from 'mdx-annotations'
export const remarkPlugins = [mdxAnnotations.remark]

23
apps/docs/next.config.mjs Normal file
View File

@ -0,0 +1,23 @@
import nextMDX from '@next/mdx'
import { remarkPlugins } from './mdx/remark.mjs'
import { rehypePlugins } from './mdx/rehype.mjs'
import { recmaPlugins } from './mdx/recma.mjs'
const withMDX = nextMDX({
options: {
remarkPlugins,
rehypePlugins,
recmaPlugins,
},
})
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx'],
experimental: {
scrollRestoration: true,
},
}
export default withMDX(nextConfig)

46
apps/docs/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "plane-docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3002",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"browserslist": "defaults, not ie <= 11",
"dependencies": {
"@algolia/autocomplete-core": "^1.7.3",
"@algolia/autocomplete-preset-algolia": "^1.7.3",
"@headlessui/react": "^1.7.7",
"@mdx-js/loader": "^2.1.5",
"@mdx-js/react": "^2.1.5",
"@next/mdx": "^13.0.3",
"@sindresorhus/slugify": "^2.1.1",
"@tailwindcss/typography": "^0.5.8",
"acorn": "^8.8.1",
"algoliasearch": "^4.14.2",
"autoprefixer": "^10.4.7",
"clsx": "^1.2.0",
"focus-visible": "^5.2.0",
"framer-motion": "7.8.1",
"mdast-util-to-string": "^3.1.0",
"mdx-annotations": "^0.1.1",
"next": "13.0.2",
"postcss-focus-visible": "^6.0.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"recma-nextjs-static-props": "^1.0.0",
"rehype-mdx-title": "^2.0.0",
"shiki": "^0.11.1",
"tailwindcss": "^3.2.4",
"unist-util-visit": "^4.1.1",
"zustand": "^4.1.4"
},
"devDependencies": {
"eslint": "8.26.0",
"eslint-config-next": "13.0.2",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.13"
}
}

View File

@ -0,0 +1,9 @@
module.exports = {
plugins: {
tailwindcss: {},
'postcss-focus-visible': {
replaceWith: '[data-focus-visible-added]',
},
autoprefixer: {},
},
}

View File

@ -0,0 +1,5 @@
module.exports = {
singleQuote: true,
semi: false,
plugins: [require('prettier-plugin-tailwindcss')],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,62 @@
import Link from 'next/link'
import clsx from 'clsx'
function ArrowIcon(props) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="m11.5 6.5 3 3.5m0 0-3 3.5m3-3.5h-9"
/>
</svg>
)
}
const variantStyles = {
primary:
'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-1 dark:ring-inset dark:ring-blue-400/20 dark:hover:bg-blue-400/10 dark:hover:text-blue-300 dark:hover:ring-blue-300',
secondary:
'rounded-full bg-zinc-100 py-1 px-3 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800/40 dark:text-zinc-400 dark:ring-1 dark:ring-inset dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300',
filled:
'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400',
outline:
'rounded-full py-1 px-3 text-zinc-700 ring-1 ring-inset ring-zinc-900/10 hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-white/5 dark:hover:text-white',
text: 'text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500',
}
export function Button({
variant = 'primary',
className,
children,
arrow,
...props
}) {
let Component = props.href ? Link : 'button'
className = clsx(
'inline-flex gap-0.5 justify-center overflow-hidden text-sm font-medium transition',
variantStyles[variant],
className
)
let arrowIcon = (
<ArrowIcon
className={clsx(
'mt-0.5 h-5 w-5',
variant === 'text' && 'relative top-px',
arrow === 'left' && '-ml-1 rotate-180',
arrow === 'right' && '-mr-1'
)}
/>
)
return (
<Component className={className} {...props}>
{arrow === 'left' && arrowIcon}
{children}
{arrow === 'right' && arrowIcon}
</Component>
)
}

View File

@ -0,0 +1,297 @@
import {
Children,
createContext,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { Tab } from '@headlessui/react'
import clsx from 'clsx'
import create from 'zustand'
import { Tag } from '@/components/Tag'
const languageNames = {
js: 'JavaScript',
ts: 'TypeScript',
javascript: 'JavaScript',
typescript: 'TypeScript',
php: 'PHP',
python: 'Python',
ruby: 'Ruby',
go: 'Go',
}
function getPanelTitle({ title, language }) {
return title ?? languageNames[language] ?? 'Code'
}
function ClipboardIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeWidth="0"
d="M5.5 13.5v-5a2 2 0 0 1 2-2l.447-.894A2 2 0 0 1 9.737 4.5h.527a2 2 0 0 1 1.789 1.106l.447.894a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2Z"
/>
<path
fill="none"
strokeLinejoin="round"
d="M12.5 6.5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m5 0-.447-.894a2 2 0 0 0-1.79-1.106h-.527a2 2 0 0 0-1.789 1.106L7.5 6.5m5 0-1 1h-3l-1-1"
/>
</svg>
)
}
function CopyButton({ code }) {
let [copyCount, setCopyCount] = useState(0)
let copied = copyCount > 0
useEffect(() => {
if (copyCount > 0) {
let timeout = setTimeout(() => setCopyCount(0), 1000)
return () => {
clearTimeout(timeout)
}
}
}, [copyCount])
return (
<button
type="button"
className={clsx(
'group/button absolute top-3.5 right-4 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100',
copied
? 'bg-blue-400/10 ring-1 ring-inset ring-blue-400/20'
: 'bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5'
)}
onClick={() => {
window.navigator.clipboard.writeText(code).then(() => {
setCopyCount((count) => count + 1)
})
}}
>
<span
aria-hidden={copied}
className={clsx(
'pointer-events-none flex items-center gap-0.5 text-zinc-400 transition duration-300',
copied && '-translate-y-1.5 opacity-0'
)}
>
<ClipboardIcon className="h-5 w-5 fill-zinc-500/20 stroke-zinc-500 transition-colors group-hover/button:stroke-zinc-400" />
Copy
</span>
<span
aria-hidden={!copied}
className={clsx(
'pointer-events-none absolute inset-0 flex items-center justify-center text-blue-400 transition duration-300',
!copied && 'translate-y-1.5 opacity-0'
)}
>
Copied!
</span>
</button>
)
}
function CodePanelHeader({ tag, label }) {
if (!tag && !label) {
return null
}
return (
<div className="flex h-9 items-center gap-2 border-y border-t-transparent border-b-white/7.5 bg-zinc-900 bg-white/2.5 px-4 dark:border-b-white/5 dark:bg-white/1">
{tag && (
<div className="dark flex">
<Tag variant="small">{tag}</Tag>
</div>
)}
{tag && label && (
<span className="h-0.5 w-0.5 rounded-full bg-zinc-500" />
)}
{label && (
<span className="font-mono text-xs text-zinc-400">{label}</span>
)}
</div>
)
}
function CodePanel({ tag, label, code, children }) {
let child = Children.only(children)
return (
<div className="group dark:bg-white/2.5">
<CodePanelHeader
tag={child.props.tag ?? tag}
label={child.props.label ?? label}
/>
<div className="relative">
<pre className="overflow-x-auto p-4 text-xs text-white">{children}</pre>
<CopyButton code={child.props.code ?? code} />
</div>
</div>
)
}
function CodeGroupHeader({ title, children, selectedIndex }) {
let hasTabs = Children.count(children) > 1
if (!title && !hasTabs) {
return null
}
return (
<div className="flex min-h-[calc(theme(spacing.12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent">
{title && (
<h3 className="mr-auto pt-3 text-xs font-semibold text-white">
{title}
</h3>
)}
{hasTabs && (
<Tab.List className="-mb-px flex gap-4 text-xs font-medium">
{Children.map(children, (child, childIndex) => (
<Tab
className={clsx(
'border-b py-3 transition focus:[&:not(:focus-visible)]:outline-none',
childIndex === selectedIndex
? 'border-blue-500 text-blue-400'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
)}
>
{getPanelTitle(child.props)}
</Tab>
))}
</Tab.List>
)}
</div>
)
}
function CodeGroupPanels({ children, ...props }) {
let hasTabs = Children.count(children) > 1
if (hasTabs) {
return (
<Tab.Panels>
{Children.map(children, (child) => (
<Tab.Panel>
<CodePanel {...props}>{child}</CodePanel>
</Tab.Panel>
))}
</Tab.Panels>
)
}
return <CodePanel {...props}>{children}</CodePanel>
}
function usePreventLayoutShift() {
let positionRef = useRef()
let rafRef = useRef()
useEffect(() => {
return () => {
window.cancelAnimationFrame(rafRef.current)
}
}, [])
return {
positionRef,
preventLayoutShift(callback) {
let initialTop = positionRef.current.getBoundingClientRect().top
callback()
rafRef.current = window.requestAnimationFrame(() => {
let newTop = positionRef.current.getBoundingClientRect().top
window.scrollBy(0, newTop - initialTop)
})
},
}
}
const usePreferredLanguageStore = create((set) => ({
preferredLanguages: [],
addPreferredLanguage: (language) =>
set((state) => ({
preferredLanguages: [
...state.preferredLanguages.filter(
(preferredLanguage) => preferredLanguage !== language
),
language,
],
})),
}))
function useTabGroupProps(availableLanguages) {
let { preferredLanguages, addPreferredLanguage } = usePreferredLanguageStore()
let [selectedIndex, setSelectedIndex] = useState(0)
let activeLanguage = [...availableLanguages].sort(
(a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a)
)[0]
let languageIndex = availableLanguages.indexOf(activeLanguage)
let newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex
if (newSelectedIndex !== selectedIndex) {
setSelectedIndex(newSelectedIndex)
}
let { positionRef, preventLayoutShift } = usePreventLayoutShift()
return {
as: 'div',
ref: positionRef,
selectedIndex,
onChange: (newSelectedIndex) => {
preventLayoutShift(() =>
addPreferredLanguage(availableLanguages[newSelectedIndex])
)
},
}
}
const CodeGroupContext = createContext(false)
export function CodeGroup({ children, title, ...props }) {
let languages = Children.map(children, (child) => getPanelTitle(child.props))
let tabGroupProps = useTabGroupProps(languages)
let hasTabs = Children.count(children) > 1
let Container = hasTabs ? Tab.Group : 'div'
let containerProps = hasTabs ? tabGroupProps : {}
let headerProps = hasTabs
? { selectedIndex: tabGroupProps.selectedIndex }
: {}
return (
<CodeGroupContext.Provider value={true}>
<Container
{...containerProps}
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
>
<CodeGroupHeader title={title} {...headerProps}>
{children}
</CodeGroupHeader>
<CodeGroupPanels {...props}>{children}</CodeGroupPanels>
</Container>
</CodeGroupContext.Provider>
)
}
export function Code({ children, ...props }) {
let isGrouped = useContext(CodeGroupContext)
if (isGrouped) {
return <code {...props} dangerouslySetInnerHTML={{ __html: children }} />
}
return <code {...props}>{children}</code>
}
export function Pre({ children, ...props }) {
let isGrouped = useContext(CodeGroupContext)
if (isGrouped) {
return children
}
return <CodeGroup {...props}>{children}</CodeGroup>
}

View File

@ -0,0 +1,228 @@
import { forwardRef, Fragment, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Transition } from '@headlessui/react'
import { Button } from '@/components/Button'
import { navigation } from '@/components/Navigation'
function CheckIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<circle cx="10" cy="10" r="10" strokeWidth="0" />
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="m6.75 10.813 2.438 2.437c1.218-4.469 4.062-6.5 4.062-6.5"
/>
</svg>
)
}
function FeedbackButton(props) {
return (
<button
type="submit"
className="px-3 text-sm font-medium text-zinc-600 transition hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
{...props}
/>
)
}
const FeedbackForm = forwardRef(function FeedbackForm({ onSubmit }, ref) {
return (
<form
ref={ref}
onSubmit={onSubmit}
className="absolute inset-0 flex items-center justify-center gap-6 md:justify-start"
>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Was this page helpful?
</p>
<div className="group grid h-8 grid-cols-[1fr,1px,1fr] overflow-hidden rounded-full border border-zinc-900/10 dark:border-white/10">
<FeedbackButton data-response="yes">Yes</FeedbackButton>
<div className="bg-zinc-900/10 dark:bg-white/10" />
<FeedbackButton data-response="no">No</FeedbackButton>
</div>
</form>
)
})
const FeedbackThanks = forwardRef(function FeedbackThanks(_props, ref) {
return (
<div
ref={ref}
className="absolute inset-0 flex justify-center md:justify-start"
>
<div className="flex items-center gap-3 rounded-full bg-blue-50/50 py-1 pr-3 pl-1.5 text-sm text-blue-900 ring-1 ring-inset ring-blue-500/20 dark:bg-blue-500/5 dark:text-blue-200 dark:ring-blue-500/30">
<CheckIcon className="h-5 w-5 flex-none fill-blue-500 stroke-white dark:fill-blue-200/20 dark:stroke-blue-200" />
Thanks for your feedback!
</div>
</div>
)
})
function Feedback() {
let [submitted, setSubmitted] = useState(false)
function onSubmit(event) {
event.preventDefault()
// event.nativeEvent.submitter.dataset.response
// => "yes" or "no"
setSubmitted(true)
}
return (
<div className="relative h-8">
<Transition
show={!submitted}
as={Fragment}
leaveFrom="opacity-100"
leaveTo="opacity-0"
leave="pointer-events-none duration-300"
>
<FeedbackForm onSubmit={onSubmit} />
</Transition>
<Transition
show={submitted}
as={Fragment}
enterFrom="opacity-0"
enterTo="opacity-100"
enter="delay-150 duration-300"
>
<FeedbackThanks />
</Transition>
</div>
)
}
function PageLink({ label, page, previous = false }) {
return (
<>
<Button
href={page.href}
aria-label={`${label}: ${page.title}`}
variant="secondary"
arrow={previous ? 'left' : 'right'}
>
{label}
</Button>
<Link
href={page.href}
tabIndex={-1}
aria-hidden="true"
className="text-base font-semibold text-zinc-900 transition hover:text-zinc-600 dark:text-white dark:hover:text-zinc-300"
>
{page.title}
</Link>
</>
)
}
function PageNavigation() {
let router = useRouter()
let allPages = navigation.flatMap((group) => group.links)
let currentPageIndex = allPages.findIndex(
(page) => page.href === router.pathname
)
if (currentPageIndex === -1) {
return null
}
let previousPage = allPages[currentPageIndex - 1]
let nextPage = allPages[currentPageIndex + 1]
if (!previousPage && !nextPage) {
return null
}
return (
<div className="flex">
{previousPage && (
<div className="flex flex-col items-start gap-3">
<PageLink label="Previous" page={previousPage} previous />
</div>
)}
{nextPage && (
<div className="ml-auto flex flex-col items-end gap-3">
<PageLink label="Next" page={nextPage} />
</div>
)}
</div>
)
}
function TwitterIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path d="M16.712 6.652c.01.146.01.29.01.436 0 4.449-3.267 9.579-9.242 9.579v-.003a8.963 8.963 0 0 1-4.98-1.509 6.379 6.379 0 0 0 4.807-1.396c-1.39-.027-2.608-.966-3.035-2.337.487.097.99.077 1.467-.059-1.514-.316-2.606-1.696-2.606-3.3v-.041c.45.26.956.404 1.475.42C3.18 7.454 2.74 5.486 3.602 3.947c1.65 2.104 4.083 3.382 6.695 3.517a3.446 3.446 0 0 1 .94-3.217 3.172 3.172 0 0 1 4.596.148 6.38 6.38 0 0 0 2.063-.817 3.357 3.357 0 0 1-1.428 1.861 6.283 6.283 0 0 0 1.865-.53 6.735 6.735 0 0 1-1.62 1.744Z" />
</svg>
)
}
function GitHubIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 1.667c-4.605 0-8.334 3.823-8.334 8.544 0 3.78 2.385 6.974 5.698 8.106.417.075.573-.182.573-.406 0-.203-.011-.875-.011-1.592-2.093.397-2.635-.522-2.802-1.002-.094-.246-.5-1.005-.854-1.207-.291-.16-.708-.556-.01-.567.656-.01 1.124.62 1.281.876.75 1.292 1.948.93 2.427.705.073-.555.291-.93.531-1.143-1.854-.213-3.791-.95-3.791-4.218 0-.929.322-1.698.854-2.296-.083-.214-.375-1.09.083-2.265 0 0 .698-.224 2.292.876a7.576 7.576 0 0 1 2.083-.288c.709 0 1.417.096 2.084.288 1.593-1.11 2.291-.875 2.291-.875.459 1.174.167 2.05.084 2.263.53.599.854 1.357.854 2.297 0 3.278-1.948 4.005-3.802 4.219.302.266.563.78.563 1.58 0 1.143-.011 2.061-.011 2.35 0 .224.156.491.573.405a8.365 8.365 0 0 0 4.11-3.116 8.707 8.707 0 0 0 1.567-4.99c0-4.721-3.73-8.545-8.334-8.545Z"
/>
</svg>
)
}
function DiscordIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path d="M16.238 4.515a14.842 14.842 0 0 0-3.664-1.136.055.055 0 0 0-.059.027 10.35 10.35 0 0 0-.456.938 13.702 13.702 0 0 0-4.115 0 9.479 9.479 0 0 0-.464-.938.058.058 0 0 0-.058-.027c-1.266.218-2.497.6-3.664 1.136a.052.052 0 0 0-.024.02C1.4 8.023.76 11.424 1.074 14.782a.062.062 0 0 0 .024.042 14.923 14.923 0 0 0 4.494 2.272.058.058 0 0 0 .064-.02c.346-.473.654-.972.92-1.496a.057.057 0 0 0-.032-.08 9.83 9.83 0 0 1-1.404-.669.058.058 0 0 1-.029-.046.058.058 0 0 1 .023-.05c.094-.07.189-.144.279-.218a.056.056 0 0 1 .058-.008c2.946 1.345 6.135 1.345 9.046 0a.056.056 0 0 1 .059.007c.09.074.184.149.28.22a.058.058 0 0 1 .023.049.059.059 0 0 1-.028.046 9.224 9.224 0 0 1-1.405.669.058.058 0 0 0-.033.033.056.056 0 0 0 .002.047c.27.523.58 1.022.92 1.495a.056.056 0 0 0 .062.021 14.878 14.878 0 0 0 4.502-2.272.055.055 0 0 0 .016-.018.056.056 0 0 0 .008-.023c.375-3.883-.63-7.256-2.662-10.246a.046.046 0 0 0-.023-.021Zm-9.223 8.221c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.717 1.814-1.618 1.814Zm5.981 0c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.71 1.814-1.618 1.814Z" />
</svg>
)
}
function SocialLink({ href, icon: Icon, children }) {
return (
<Link href={href} className="group">
<span className="sr-only">{children}</span>
<Icon className="h-5 w-5 fill-zinc-700 transition group-hover:fill-zinc-900 dark:group-hover:fill-zinc-500" />
</Link>
)
}
function SmallPrint() {
return (
<div className="flex flex-col items-center justify-between gap-5 border-t border-zinc-900/5 pt-8 dark:border-white/5 sm:flex-row">
<p className="text-xs text-zinc-600 dark:text-zinc-400">
&copy; Copyrights Plane {new Date().getFullYear()}. All rights reserved.
</p>
<div className="flex gap-4">
<SocialLink href="https://twitter.com/planepowers" icon={TwitterIcon}>
Follow us on Twitter
</SocialLink>
<SocialLink href="https://github.com/makeplane" icon={GitHubIcon}>
Follow us on GitHub
</SocialLink>
<SocialLink href="https://discord.com/invite/A92xrEGCge" icon={DiscordIcon}>
Join our Discord server
</SocialLink>
</div>
</div>
)
}
export function Footer() {
let router = useRouter()
return (
<footer className="mx-auto max-w-2xl space-y-10 pb-16 lg:max-w-5xl">
<Feedback key={router.pathname} />
<PageNavigation />
<SmallPrint />
</footer>
)
}

View File

@ -0,0 +1,42 @@
import { useId } from 'react'
export function GridPattern({ width, height, x, y, squares, ...props }) {
let patternId = useId()
return (
<svg aria-hidden="true" {...props}>
<defs>
<pattern
id={patternId}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
</pattern>
</defs>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill={`url(#${patternId})`}
/>
{squares && (
<svg x={x} y={y} className="overflow-visible">
{squares.map(([x, y]) => (
<rect
strokeWidth="0"
key={`${x}-${y}`}
width={width + 1}
height={height + 1}
x={x * width}
y={y * height}
/>
))}
</svg>
)}
</svg>
)
}

View File

@ -0,0 +1,54 @@
import { Button } from '@/components/Button'
import { Heading } from '@/components/Heading'
const guides = [
{
href: '/authentication',
name: 'Authentication',
description: 'Learn how to authenticate your API requests.',
},
{
href: '/pagination',
name: 'Pagination',
description: 'Understand how to work with paginated responses.',
},
{
href: '/errors',
name: 'Errors',
description:
'Read about the different types of errors returned by the API.',
},
{
href: '/webhooks',
name: 'Webhooks',
description:
'Learn how to programmatically configure webhooks for your app.',
},
]
export function Guides() {
return (
<div className="my-16 xl:max-w-none">
<Heading level={2} id="guides">
Guides
</Heading>
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
{guides.map((guide) => (
<div key={guide.href}>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
{guide.name}
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{guide.description}
</p>
<p className="mt-4">
<Button href={guide.href} variant="text" arrow="right">
Read more
</Button>
</p>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
import { forwardRef } from 'react'
import Link from 'next/link'
import clsx from 'clsx'
import { motion, useScroll, useTransform } from 'framer-motion'
import { Button } from '@/components/Button'
import { Logo } from '@/components/Logo'
import {
MobileNavigation,
useIsInsideMobileNavigation,
} from '@/components/MobileNavigation'
import { useMobileNavigationStore } from '@/components/MobileNavigation'
import { ModeToggle } from '@/components/ModeToggle'
import { MobileSearch, Search } from '@/components/Search'
function TopLevelNavItem({ href, children }) {
return (
<li>
<Link
href={href}
className="text-sm leading-5 text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
>
{children}
</Link>
</li>
)
}
export const Header = forwardRef(function Header({ className }, ref) {
let { isOpen: mobileNavIsOpen } = useMobileNavigationStore()
let isInsideMobileNavigation = useIsInsideMobileNavigation()
let { scrollY } = useScroll()
let bgOpacityLight = useTransform(scrollY, [0, 72], [0.5, 0.9])
let bgOpacityDark = useTransform(scrollY, [0, 72], [0.2, 0.8])
return (
<motion.div
ref={ref}
className={clsx(
className,
'fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:z-30 lg:px-8',
!isInsideMobileNavigation &&
'backdrop-blur-sm dark:backdrop-blur lg:left-72 xl:left-80',
isInsideMobileNavigation
? 'bg-white dark:bg-zinc-900'
: 'bg-white/[var(--bg-opacity-light)] dark:bg-zinc-900/[var(--bg-opacity-dark)]'
)}
style={{
'--bg-opacity-light': bgOpacityLight,
'--bg-opacity-dark': bgOpacityDark,
}}
>
<div
className={clsx(
'absolute inset-x-0 top-full h-px transition',
(isInsideMobileNavigation || !mobileNavIsOpen) &&
'bg-zinc-900/7.5 dark:bg-white/7.5'
)}
/>
<Search />
<div className="flex items-center gap-5 lg:hidden">
<MobileNavigation />
<Link href="/" aria-label="Home">
<Logo />
</Link>
</div>
<div className="flex items-center gap-5">
<nav className="hidden md:block">
<ul role="list" className="flex items-center gap-8">
<TopLevelNavItem href="https://plane.so/">Plane Cloud</TopLevelNavItem>
<TopLevelNavItem href="https://github.com/makeplane/plane">GitHub</TopLevelNavItem>
<TopLevelNavItem href="https://discord.com/invite/A92xrEGCge">Support</TopLevelNavItem>
</ul>
</nav>
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
<div className="flex gap-4">
<MobileSearch />
<ModeToggle />
</div>
<div className="hidden min-[416px]:contents">
<Button href="https://app.plane.so/signin">Sign in</Button>
</div>
</div>
</motion.div>
)
})

View File

@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react'
import Link from 'next/link'
import { useInView } from 'framer-motion'
import { useSectionStore } from '@/components/SectionProvider'
import { Tag } from '@/components/Tag'
import { remToPx } from '@/lib/remToPx'
function AnchorIcon(props) {
return (
<svg
viewBox="0 0 20 20"
fill="none"
strokeLinecap="round"
aria-hidden="true"
{...props}
>
<path d="m6.5 11.5-.964-.964a3.535 3.535 0 1 1 5-5l.964.964m2 2 .964.964a3.536 3.536 0 0 1-5 5L8.5 13.5m0-5 3 3" />
</svg>
)
}
function Eyebrow({ tag, label }) {
if (!tag && !label) {
return null
}
return (
<div className="flex items-center gap-x-3">
{tag && <Tag>{tag}</Tag>}
{tag && label && (
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
)}
{label && (
<span className="font-mono text-xs text-zinc-400">{label}</span>
)}
</div>
)
}
function Anchor({ id, inView, children }) {
return (
<Link
href={`#${id}`}
className="group text-inherit no-underline hover:text-inherit"
>
{inView && (
<div className="absolute mt-1 ml-[calc(-1*var(--width))] hidden w-[var(--width)] opacity-0 transition [--width:calc(2.625rem+0.5px+50%-min(50%,calc(theme(maxWidth.lg)+theme(spacing.8))))] group-hover:opacity-100 group-focus:opacity-100 md:block lg:z-50 2xl:[--width:theme(spacing.10)]">
<div className="group/anchor block h-5 w-5 rounded-lg bg-zinc-50 ring-1 ring-inset ring-zinc-300 transition hover:ring-zinc-500 dark:bg-zinc-800 dark:ring-zinc-700 dark:hover:bg-zinc-700 dark:hover:ring-zinc-600">
<AnchorIcon className="h-5 w-5 stroke-zinc-500 transition dark:stroke-zinc-400 dark:group-hover/anchor:stroke-white" />
</div>
</div>
)}
{children}
</Link>
)
}
export function Heading({
level = 2,
children,
id,
tag,
label,
anchor = true,
...props
}) {
let Component = `h${level}`
let ref = useRef()
let registerHeading = useSectionStore((s) => s.registerHeading)
let inView = useInView(ref, {
margin: `${remToPx(-3.5)}px 0px 0px 0px`,
amount: 'all',
})
useEffect(() => {
if (level === 2) {
registerHeading({ id, ref, offsetRem: tag || label ? 8 : 6 })
}
})
return (
<>
<Eyebrow tag={tag} label={label} />
<Component
ref={ref}
id={anchor ? id : undefined}
className={tag || label ? 'mt-2 scroll-mt-32' : 'scroll-mt-24'}
{...props}
>
{anchor ? (
<Anchor id={id} inView={inView}>
{children}
</Anchor>
) : (
children
)}
</Component>
</>
)
}

View File

@ -0,0 +1,32 @@
import { GridPattern } from '@/components/GridPattern'
export function HeroPattern() {
return (
<div className="absolute inset-0 -z-10 mx-0 max-w-none overflow-hidden">
<div className="absolute left-1/2 top-0 ml-[-38rem] h-[25rem] w-[81.25rem] dark:[mask-image:linear-gradient(white,transparent)]">
<div className="absolute inset-0 bg-gradient-to-r from-[#36b49f] to-[#DBFF75] opacity-40 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-[#36b49f]/30 dark:to-[#DBFF75]/30 dark:opacity-100">
<GridPattern
width={72}
height={56}
x="-12"
y="4"
squares={[
[4, 3],
[2, 1],
[7, 3],
[10, 6],
]}
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
/>
</div>
<svg
viewBox="0 0 1113 440"
aria-hidden="true"
className="absolute top-0 left-1/2 ml-[-19rem] w-[69.5625rem] fill-white blur-[26px] dark:hidden"
>
<path d="M.016 439.5s-9.5-300 434-300S882.516 20 882.516 20V0h230.004v439.5H.016Z" />
</svg>
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
import Link from 'next/link'
import { motion } from 'framer-motion'
import { Footer } from '@/components/Footer'
import { Header } from '@/components/Header'
import { Logo } from '@/components/Logo'
import { Navigation } from '@/components/Navigation'
import { Prose } from '@/components/Prose'
import { SectionProvider } from '@/components/SectionProvider'
export function Layout({ children, sections = [] }) {
return (
<SectionProvider sections={sections}>
<div className="lg:ml-72 xl:ml-80">
<motion.header
layoutScroll
className="fixed inset-y-0 left-0 z-40 contents w-72 overflow-y-auto border-r border-zinc-900/10 px-6 pt-4 pb-8 dark:border-white/10 lg:block xl:w-80"
>
<div className="hidden lg:flex">
<Link href="/" aria-label="Home">
<Logo />
</Link>
</div>
<Header />
<Navigation className="hidden lg:mt-5 lg:block" />
</motion.header>
<div className="relative px-4 pt-14 sm:px-6 lg:px-8">
<main className="py-16">
<Prose as="article">{children}</Prose>
</main>
<Footer />
</div>
</div>
</SectionProvider>
)
}

View File

@ -0,0 +1,82 @@
import Image from 'next/image'
import { Button } from '@/components/Button'
import { Heading } from '@/components/Heading'
import logoGo from '@/images/logos/go.svg'
import logoNode from '@/images/logos/node.svg'
import logoPhp from '@/images/logos/php.svg'
import logoPython from '@/images/logos/python.svg'
import logoRuby from '@/images/logos/ruby.svg'
const libraries = [
{
href: '#',
name: 'PHP',
description:
'A popular general-purpose scripting language that is especially suited to web development.',
logo: logoPhp,
},
{
href: '#',
name: 'Ruby',
description:
'A dynamic, open source programming language with a focus on simplicity and productivity.',
logo: logoRuby,
},
{
href: '#',
name: 'Node.js',
description:
'Node.js® is an open-source, cross-platform JavaScript runtime environment.',
logo: logoNode,
},
{
href: '#',
name: 'Python',
description:
'Python is a programming language that lets you work quickly and integrate systems more effectively.',
logo: logoPython,
},
{
href: '#',
name: 'Go',
description:
'An open-source programming language supported by Google with built-in concurrency.',
logo: logoGo,
},
]
export function Libraries() {
return (
<div className="my-16 xl:max-w-none">
<Heading level={2} id="official-libraries">
Official libraries
</Heading>
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3">
{libraries.map((library) => (
<div key={library.name} className="flex flex-row-reverse gap-6">
<div className="flex-auto">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
{library.name}
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{library.description}
</p>
<p className="mt-4">
<Button href={library.href} variant="text" arrow="right">
Read more
</Button>
</p>
</div>
<Image
src={library.logo}
alt=""
className="h-12 w-12"
unoptimized
/>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,5 @@
export function Logo() {
return (
<img src="https://camo.githubusercontent.com/ef32512ae486a8cef8e000e74b2ff11c92c89c2cadb2d79674c6bd1599b99a56/68747470733a2f2f696b2e696d6167656b69742e696f2f77326f6b77627475322f706c616e652d6c6f676f5f306d383378756537522e706e673f696b2d73646b2d76657273696f6e3d6a6176617363726970742d312e342e33267570646174656441743d31363638383632373137303834" height={100} width={100} alt="Plane"/>
)
}

View File

@ -0,0 +1,115 @@
import { createContext, Fragment, useContext } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { motion } from 'framer-motion'
import create from 'zustand'
import { Header } from '@/components/Header'
import { Navigation } from '@/components/Navigation'
function MenuIcon(props) {
return (
<svg
viewBox="0 0 10 9"
fill="none"
strokeLinecap="round"
aria-hidden="true"
{...props}
>
<path d="M.5 1h9M.5 8h9M.5 4.5h9" />
</svg>
)
}
function XIcon(props) {
return (
<svg
viewBox="0 0 10 9"
fill="none"
strokeLinecap="round"
aria-hidden="true"
{...props}
>
<path d="m1.5 1 7 7M8.5 1l-7 7" />
</svg>
)
}
const IsInsideMobileNavigationContext = createContext(false)
export function useIsInsideMobileNavigation() {
return useContext(IsInsideMobileNavigationContext)
}
export const useMobileNavigationStore = create((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
}))
export function MobileNavigation() {
let isInsideMobileNavigation = useIsInsideMobileNavigation()
let { isOpen, toggle, close } = useMobileNavigationStore()
let ToggleIcon = isOpen ? XIcon : MenuIcon
return (
<IsInsideMobileNavigationContext.Provider value={true}>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
aria-label="Toggle navigation"
onClick={toggle}
>
<ToggleIcon className="w-2.5 stroke-zinc-900 dark:stroke-white" />
</button>
{!isInsideMobileNavigation && (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog onClose={close} className="fixed inset-0 z-50 lg:hidden">
<Transition.Child
as={Fragment}
enter="duration-300 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-200 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 top-14 bg-zinc-400/20 backdrop-blur-sm dark:bg-black/40" />
</Transition.Child>
<Dialog.Panel>
<Transition.Child
as={Fragment}
enter="duration-300 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-200 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Header />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="duration-500 ease-in-out"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="duration-500 ease-in-out"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<motion.div
layoutScroll
className="fixed left-0 top-14 bottom-0 w-full overflow-y-auto bg-white px-4 pt-6 pb-4 shadow-lg shadow-zinc-900/10 ring-1 ring-zinc-900/7.5 dark:bg-zinc-900 dark:ring-zinc-800 min-[416px]:max-w-sm sm:px-6 sm:pb-10"
>
<Navigation />
</motion.div>
</Transition.Child>
</Dialog.Panel>
</Dialog>
</Transition.Root>
)}
</IsInsideMobileNavigationContext.Provider>
)
}

View File

@ -0,0 +1,54 @@
function SunIcon(props) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<path d="M12.5 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z" />
<path
strokeLinecap="round"
d="M10 5.5v-1M13.182 6.818l.707-.707M14.5 10h1M13.182 13.182l.707.707M10 15.5v-1M6.11 13.889l.708-.707M4.5 10h1M6.11 6.111l.708.707"
/>
</svg>
)
}
function MoonIcon(props) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<path d="M15.224 11.724a5.5 5.5 0 0 1-6.949-6.949 5.5 5.5 0 1 0 6.949 6.949Z" />
</svg>
)
}
export function ModeToggle() {
function disableTransitionsTemporarily() {
document.documentElement.classList.add('[&_*]:!transition-none')
window.setTimeout(() => {
document.documentElement.classList.remove('[&_*]:!transition-none')
}, 0)
}
function toggleMode() {
disableTransitionsTemporarily()
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
let isSystemDarkMode = darkModeMediaQuery.matches
let isDarkMode = document.documentElement.classList.toggle('dark')
if (isDarkMode === isSystemDarkMode) {
delete window.localStorage.isDarkMode
} else {
window.localStorage.isDarkMode = isDarkMode
}
}
return (
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
aria-label="Toggle dark mode"
onClick={toggleMode}
>
<SunIcon className="h-5 w-5 stroke-zinc-900 dark:hidden" />
<MoonIcon className="hidden h-5 w-5 stroke-white dark:block" />
</button>
)
}

View File

@ -0,0 +1,236 @@
import { useRef } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { AnimatePresence, motion, useIsPresent } from 'framer-motion'
import { Button } from '@/components/Button'
import { useIsInsideMobileNavigation } from '@/components/MobileNavigation'
import { useSectionStore } from '@/components/SectionProvider'
import { Tag } from '@/components/Tag'
import { remToPx } from '@/lib/remToPx'
function useInitialValue(value, condition = true) {
let initialValue = useRef(value).current
return condition ? initialValue : value
}
function TopLevelNavItem({ href, children }) {
return (
<li className="md:hidden">
<Link
href={href}
className="block py-1 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
>
{children}
</Link>
</li>
)
}
function NavLink({ href, tag, active, isAnchorLink = false, children }) {
return (
<Link
href={href}
aria-current={active ? 'page' : undefined}
className={clsx(
'flex justify-between gap-2 py-1 pr-3 text-sm transition',
isAnchorLink ? 'pl-7' : 'pl-4',
active
? 'text-zinc-900 dark:text-white'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'
)}
>
<span className="truncate">{children}</span>
{tag && (
<Tag variant="small" color="zinc">
{tag}
</Tag>
)}
</Link>
)
}
function VisibleSectionHighlight({ group, pathname }) {
let [sections, visibleSections] = useInitialValue(
[
useSectionStore((s) => s.sections),
useSectionStore((s) => s.visibleSections),
],
useIsInsideMobileNavigation()
)
let isPresent = useIsPresent()
let firstVisibleSectionIndex = Math.max(
0,
[{ id: '_top' }, ...sections].findIndex(
(section) => section.id === visibleSections[0]
)
)
let itemHeight = remToPx(2)
let height = isPresent
? Math.max(1, visibleSections.length) * itemHeight
: itemHeight
let top =
group.links.findIndex((link) => link.href === pathname) * itemHeight +
firstVisibleSectionIndex * itemHeight
return (
<motion.div
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.2 } }}
exit={{ opacity: 0 }}
className="absolute inset-x-0 top-0 bg-zinc-800/2.5 will-change-transform dark:bg-white/2.5"
style={{ borderRadius: 8, height, top }}
/>
)
}
function ActivePageMarker({ group, pathname }) {
let itemHeight = remToPx(2)
let offset = remToPx(0.25)
let activePageIndex = group.links.findIndex((link) => link.href === pathname)
let top = offset + activePageIndex * itemHeight
return (
<motion.div
layout
className="absolute left-2 h-6 w-px bg-blue-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.2 } }}
exit={{ opacity: 0 }}
style={{ top }}
/>
)
}
function NavigationGroup({ group, className }) {
// If this is the mobile navigation then we always render the initial
// state, so that the state does not change during the close animation.
// The state will still update when we re-open (re-render) the navigation.
let isInsideMobileNavigation = useIsInsideMobileNavigation()
let [router, sections] = useInitialValue(
[useRouter(), useSectionStore((s) => s.sections)],
isInsideMobileNavigation
)
let isActiveGroup =
group.links.findIndex((link) => link.href === router.pathname) !== -1
return (
<li className={clsx('relative mt-6', className)}>
<motion.h2
layout="position"
className="text-xs font-semibold text-zinc-900 dark:text-white"
>
{group.title}
</motion.h2>
<div className="relative mt-3 pl-2">
<AnimatePresence initial={!isInsideMobileNavigation}>
{isActiveGroup && (
<VisibleSectionHighlight group={group} pathname={router.pathname} />
)}
</AnimatePresence>
<motion.div
layout
className="absolute inset-y-0 left-2 w-px bg-zinc-900/10 dark:bg-white/5"
/>
<AnimatePresence initial={false}>
{isActiveGroup && (
<ActivePageMarker group={group} pathname={router.pathname} />
)}
</AnimatePresence>
<ul role="list" className="border-l border-transparent">
{group.links.map((link) => (
<motion.li key={link.href} layout="position" className="relative">
<NavLink href={link.href} active={link.href === router.pathname}>
{link.title}
</NavLink>
<AnimatePresence mode="popLayout" initial={false}>
{link.href === router.pathname && sections.length > 0 && (
<motion.ul
role="list"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { delay: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.15 },
}}
>
{sections.map((section) => (
<li key={section.id}>
<NavLink
href={`${link.href}#${section.id}`}
tag={section.tag}
isAnchorLink
>
{section.title}
</NavLink>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</motion.li>
))}
</ul>
</div>
</li>
)
}
export const navigation = [
{
title: 'Guides',
links: [
{ title: 'Introduction', href: '/' },
{ title: 'Get Started', href: '/get-started' },
{ title: 'Self Hosting', href: '/self-hosting' },
{ title: 'Plane Basics', href: '/plane-basics' },
// { title: 'Quickstart', href: '/quickstart' },
// { title: 'SDKs', href: '/sdks' },
// { title: 'Authentication', href: '/authentication' },
// { title: 'Pagination', href: '/pagination' },
// { title: 'Errors', href: '/errors' },
// { title: 'Webhooks', href: '/webhooks' },
],
},
// {
// title: 'Resources',
// links: [
// { title: 'Contacts', href: '/contacts' },
// { title: 'Conversations', href: '/conversations' },
// { title: 'Messages', href: '/messages' },
// { title: 'Groups', href: '/groups' },
// { title: 'Attachments', href: '/attachments' },
// ],
// },
]
export function Navigation(props) {
return (
<nav {...props}>
<ul role="list">
<TopLevelNavItem href="/">API</TopLevelNavItem>
<TopLevelNavItem href="#">Documentation</TopLevelNavItem>
<TopLevelNavItem href="#">Support</TopLevelNavItem>
{navigation.map((group, groupIndex) => (
<NavigationGroup
key={group.title}
group={group}
className={groupIndex === 0 && 'md:mt-0'}
/>
))}
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
<Button href="#" variant="filled" className="w-full">
Sign in
</Button>
</li>
</ul>
</nav>
)
}

View File

@ -0,0 +1,10 @@
import clsx from 'clsx'
export function Prose({ as: Component = 'div', className, ...props }) {
return (
<Component
className={clsx(className, 'prose dark:prose-invert')}
{...props}
/>
)
}

View File

@ -0,0 +1,157 @@
import Link from 'next/link'
import { motion, useMotionTemplate, useMotionValue } from 'framer-motion'
import { GridPattern } from '@/components/GridPattern'
import { Heading } from '@/components/Heading'
import { ChatBubbleIcon } from '@/components/icons/ChatBubbleIcon'
import { EnvelopeIcon } from '@/components/icons/EnvelopeIcon'
import { UserIcon } from '@/components/icons/UserIcon'
import { UsersIcon } from '@/components/icons/UsersIcon'
const resources = [
{
href: '/get-started',
name: 'Get Started',
description:
'Learn how to use Plane and follow the best practices of taking-off.',
icon: UserIcon,
pattern: {
y: 16,
squares: [
[0, 1],
[1, 3],
],
},
},
{
href: '/self-host',
name: 'Self-host Plane',
description:
'Run Plane on your computer or development machine.',
icon: ChatBubbleIcon,
pattern: {
y: -6,
squares: [
[-1, 2],
[1, 3],
],
},
},
{
href: '/plane-basics',
name: 'Plane Basics',
description:
'Learn about Plane basic features and kickstart your workspace',
icon: EnvelopeIcon,
pattern: {
y: 32,
squares: [
[0, 2],
[1, 4],
],
},
},
{
href: '/',
name: 'Community',
description:
'Hang out with truly exceptional devs & designers on Discord.',
icon: UsersIcon,
pattern: {
y: 22,
squares: [[0, 1]],
},
},
]
function ResourceIcon({ icon: Icon }) {
return (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-zinc-900/5 ring-1 ring-zinc-900/25 backdrop-blur-[2px] transition duration-300 group-hover:bg-white/50 group-hover:ring-zinc-900/25 dark:bg-white/7.5 dark:ring-white/15 dark:group-hover:bg-blue-300/10 dark:group-hover:ring-blue-400">
<Icon className="h-5 w-5 fill-zinc-700/10 stroke-zinc-700 transition-colors duration-300 group-hover:stroke-zinc-900 dark:fill-white/10 dark:stroke-zinc-400 dark:group-hover:fill-blue-300/10 dark:group-hover:stroke-blue-400" />
</div>
)
}
function ResourcePattern({ mouseX, mouseY, ...gridProps }) {
let maskImage = useMotionTemplate`radial-gradient(180px at ${mouseX}px ${mouseY}px, white, transparent)`
let style = { maskImage, WebkitMaskImage: maskImage }
return (
<div className="pointer-events-none">
<div className="absolute inset-0 rounded-2xl transition duration-300 [mask-image:linear-gradient(white,transparent)] group-hover:opacity-50">
<GridPattern
width={72}
height={56}
x="50%"
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/[0.02] stroke-black/5 dark:fill-white/1 dark:stroke-white/2.5"
{...gridProps}
/>
</div>
<motion.div
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-[#D7EDEA] to-[#F4FBDF] opacity-0 transition duration-300 group-hover:opacity-100 dark:from-[#202D2E] dark:to-[#303428]"
style={style}
/>
<motion.div
className="absolute inset-0 rounded-2xl opacity-0 mix-blend-overlay transition duration-300 group-hover:opacity-100"
style={style}
>
<GridPattern
width={72}
height={56}
x="50%"
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/50 stroke-black/70 dark:fill-white/2.5 dark:stroke-white/10"
{...gridProps}
/>
</motion.div>
</div>
)
}
function Resource({ resource }) {
let mouseX = useMotionValue(0)
let mouseY = useMotionValue(0)
function onMouseMove({ currentTarget, clientX, clientY }) {
let { left, top } = currentTarget.getBoundingClientRect()
mouseX.set(clientX - left)
mouseY.set(clientY - top)
}
return (
<div
key={resource.href}
onMouseMove={onMouseMove}
className="group relative flex rounded-2xl bg-zinc-50 transition-shadow hover:shadow-md hover:shadow-zinc-900/5 dark:bg-white/2.5 dark:hover:shadow-black/5"
>
<ResourcePattern {...resource.pattern} mouseX={mouseX} mouseY={mouseY} />
<div className="absolute inset-0 rounded-2xl ring-1 ring-inset ring-zinc-900/7.5 group-hover:ring-zinc-900/10 dark:ring-white/10 dark:group-hover:ring-white/20" />
<div className="relative rounded-2xl px-4 pt-16 pb-4">
<ResourceIcon icon={resource.icon} />
<h3 className="mt-4 text-sm font-semibold leading-7 text-zinc-900 dark:text-white">
<Link href={resource.href}>
<span className="absolute inset-0 rounded-2xl" />
{resource.name}
</Link>
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{resource.description}
</p>
</div>
</div>
)
}
export function Resources() {
return (
<div className="my-16 xl:max-w-none">
<Heading level={2} id="resources">
Resources
</Heading>
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
{resources.map((resource) => (
<Resource key={resource.href} resource={resource} />
))}
</div>
</div>
)
}

View File

@ -0,0 +1,515 @@
import { forwardRef, Fragment, useEffect, useId, useRef, useState } from 'react'
import { useRouter } from 'next/router'
import { createAutocomplete } from '@algolia/autocomplete-core'
import { getAlgoliaResults } from '@algolia/autocomplete-preset-algolia'
import { Dialog, Transition } from '@headlessui/react'
import algoliasearch from 'algoliasearch/lite'
import clsx from 'clsx'
const searchClient = algoliasearch(
process.env.NEXT_PUBLIC_DOCSEARCH_APP_ID,
process.env.NEXT_PUBLIC_DOCSEARCH_API_KEY
)
function useAutocomplete() {
let id = useId()
let router = useRouter()
let [autocompleteState, setAutocompleteState] = useState({})
let [autocomplete] = useState(() =>
createAutocomplete({
id,
placeholder: 'Find something...',
defaultActiveItemId: 0,
onStateChange({ state }) {
setAutocompleteState(state)
},
shouldPanelOpen({ state }) {
return state.query !== ''
},
navigator: {
navigate({ itemUrl }) {
autocomplete.setIsOpen(true)
router.push(itemUrl)
},
},
getSources() {
return [
{
sourceId: 'documentation',
getItemInputValue({ item }) {
return item.query
},
getItemUrl({ item }) {
let url = new URL(item.url)
return `${url.pathname}${url.hash}`
},
onSelect({ itemUrl }) {
router.push(itemUrl)
},
getItems({ query }) {
return getAlgoliaResults({
searchClient,
queries: [
{
query,
indexName: process.env.NEXT_PUBLIC_DOCSEARCH_INDEX_NAME,
params: {
hitsPerPage: 5,
highlightPreTag:
'<mark class="underline bg-transparent text-blue-500">',
highlightPostTag: '</mark>',
},
},
],
})
},
},
]
},
})
)
return { autocomplete, autocompleteState }
}
function resolveResult(result) {
let allLevels = Object.keys(result.hierarchy)
let hierarchy = Object.entries(result._highlightResult.hierarchy).filter(
([, { value }]) => Boolean(value)
)
let levels = hierarchy.map(([level]) => level)
let level =
result.type === 'content'
? levels.pop()
: levels
.filter(
(level) =>
allLevels.indexOf(level) <= allLevels.indexOf(result.type)
)
.pop()
return {
titleHtml: result._highlightResult.hierarchy[level].value,
hierarchyHtml: hierarchy
.slice(0, levels.indexOf(level))
.map(([, { value }]) => value),
}
}
function SearchIcon(props) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
/>
</svg>
)
}
function NoResultsIcon(props) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12.01 12a4.237 4.237 0 0 0 1.24-3c0-.62-.132-1.207-.37-1.738M12.01 12A4.237 4.237 0 0 1 9 13.25c-.635 0-1.237-.14-1.777-.388M12.01 12l3.24 3.25m-3.715-9.661a4.25 4.25 0 0 0-5.975 5.908M4.5 15.5l11-11"
/>
</svg>
)
}
function LoadingIcon(props) {
let id = useId()
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<circle cx="10" cy="10" r="5.5" strokeLinejoin="round" />
<path
stroke={`url(#${id})`}
strokeLinecap="round"
strokeLinejoin="round"
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
/>
<defs>
<linearGradient
id={id}
x1="13"
x2="9.5"
y1="9"
y2="15"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
)
}
function SearchResult({ result, resultIndex, autocomplete, collection }) {
let id = useId()
let { titleHtml, hierarchyHtml } = resolveResult(result)
return (
<li
className={clsx(
'group block cursor-default px-4 py-3 aria-selected:bg-zinc-50 dark:aria-selected:bg-zinc-800/50',
resultIndex > 0 && 'border-t border-zinc-100 dark:border-zinc-800'
)}
aria-labelledby={`${id}-hierarchy ${id}-title`}
{...autocomplete.getItemProps({
item: result,
source: collection.source,
})}
>
<div
id={`${id}-title`}
aria-hidden="true"
className="text-sm font-medium text-zinc-900 group-aria-selected:text-blue-500 dark:text-white"
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
{hierarchyHtml.length > 0 && (
<div
id={`${id}-hierarchy`}
aria-hidden="true"
className="mt-1 truncate whitespace-nowrap text-2xs text-zinc-500"
>
{hierarchyHtml.map((item, itemIndex, items) => (
<Fragment key={itemIndex}>
<span dangerouslySetInnerHTML={{ __html: item }} />
<span
className={
itemIndex === items.length - 1
? 'sr-only'
: 'mx-2 text-zinc-300 dark:text-zinc-700'
}
>
/
</span>
</Fragment>
))}
</div>
)}
</li>
)
}
function SearchResults({ autocomplete, query, collection }) {
if (collection.items.length === 0) {
return (
<div className="p-6 text-center">
<NoResultsIcon className="mx-auto h-5 w-5 stroke-zinc-900 dark:stroke-zinc-600" />
<p className="mt-2 text-xs text-zinc-700 dark:text-zinc-400">
Nothing found for{' '}
<strong className="break-words font-semibold text-zinc-900 dark:text-white">
&lsquo;{query}&rsquo;
</strong>
. Please try again.
</p>
</div>
)
}
return (
<ul role="list" {...autocomplete.getListProps()}>
{collection.items.map((result, resultIndex) => (
<SearchResult
key={result.objectID}
result={result}
resultIndex={resultIndex}
autocomplete={autocomplete}
collection={collection}
/>
))}
</ul>
)
}
const SearchInput = forwardRef(function SearchInput(
{ autocomplete, autocompleteState, onClose },
inputRef
) {
let inputProps = autocomplete.getInputProps({})
return (
<div className="group relative flex h-12">
<SearchIcon className="pointer-events-none absolute left-3 top-0 h-full w-5 stroke-zinc-500" />
<input
ref={inputRef}
className={clsx(
'flex-auto appearance-none bg-transparent pl-10 text-zinc-900 outline-none placeholder:text-zinc-500 focus:w-full focus:flex-none dark:text-white sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden',
autocompleteState.status === 'stalled' ? 'pr-11' : 'pr-4'
)}
{...inputProps}
onKeyDown={(event) => {
if (
event.key === 'Escape' &&
!autocompleteState.isOpen &&
autocompleteState.query === ''
) {
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
document.activeElement?.blur()
onClose()
} else {
inputProps.onKeyDown(event)
}
}}
/>
{autocompleteState.status === 'stalled' && (
<div className="absolute inset-y-0 right-3 flex items-center">
<LoadingIcon className="h-5 w-5 animate-spin stroke-zinc-200 text-zinc-900 dark:stroke-zinc-800 dark:text-blue-400" />
</div>
)}
</div>
)
})
function AlgoliaLogo(props) {
return (
<svg viewBox="0 0 71 16" role="img" aria-label="Algolia" {...props}>
<path
fillRule="evenodd"
d="M34.98 8.81V.19a.189.189 0 0 0-.218-.186l-1.615.254a.19.19 0 0 0-.16.187l.006 8.741c0 .414 0 2.966 3.07 3.056a.19.19 0 0 0 .195-.19v-1.304a.187.187 0 0 0-.164-.187c-1.115-.128-1.115-1.522-1.115-1.75v-.002Z"
clipRule="evenodd"
/>
<path d="M61.605 3.352H59.98a.189.189 0 0 0-.189.189v8.514c0 .104.085.189.189.189h1.625a.189.189 0 0 0 .188-.19V3.542a.189.189 0 0 0-.188-.189Z" />
<path
fillRule="evenodd"
d="M59.98 2.285h1.625a.189.189 0 0 0 .188-.189V.19a.189.189 0 0 0-.218-.187l-1.624.255a.189.189 0 0 0-.16.186v1.652c0 .104.085.189.189.189ZM57.172 8.81V.19a.189.189 0 0 0-.218-.186l-1.615.254a.19.19 0 0 0-.16.187l.006 8.741c0 .414 0 2.966 3.07 3.056a.19.19 0 0 0 .196-.19v-1.304a.187.187 0 0 0-.164-.187c-1.115-.128-1.115-1.522-1.115-1.75v-.002ZM52.946 4.568a3.628 3.628 0 0 0-1.304-.906 4.347 4.347 0 0 0-1.666-.315c-.601 0-1.157.101-1.662.315a3.822 3.822 0 0 0-1.304.906c-.367.39-.652.86-.856 1.408-.204.55-.296 1.196-.296 1.868 0 .671.103 1.18.306 1.734.204.554.484 1.027.846 1.42.361.39.795.691 1.3.91.504.218 1.283.33 1.676.335.392 0 1.177-.122 1.686-.335.51-.214.943-.52 1.305-.91.361-.393.641-.866.84-1.42.199-.555.295-1.063.295-1.734 0-.672-.107-1.318-.32-1.868a4.203 4.203 0 0 0-.846-1.408Zm-1.421 5.239c-.367.504-.882.758-1.539.758-.657 0-1.172-.25-1.539-.758-.367-.504-.55-1.088-.55-1.958 0-.86.178-1.573.545-2.076.367-.504.882-.752 1.538-.752.658 0 1.172.248 1.539.752.367.498.556 1.215.556 2.076 0 .87-.184 1.449-.55 1.958ZM29.35 3.352H27.77c-1.547 0-2.909.815-3.703 2.051a4.643 4.643 0 0 0-.736 2.519 4.611 4.611 0 0 0 1.949 3.783 2.574 2.574 0 0 0 1.542.428l.034-.002.084-.006.032-.004.088-.011.02-.003c1.052-.163 1.97-.986 2.268-2.01v1.85c0 .105.085.19.19.19h1.612a.189.189 0 0 0 .19-.19V3.541a.189.189 0 0 0-.19-.189H29.35Zm0 6.62c-.39.326-.896.448-1.435.484l-.016.002a1.68 1.68 0 0 1-.107.003c-1.352 0-2.468-1.149-2.468-2.54 0-.328.063-.64.173-.927.36-.932 1.241-1.591 2.274-1.591h1.578v4.57ZM69.009 3.352H67.43c-1.547 0-2.908.815-3.703 2.051a4.643 4.643 0 0 0-.736 2.519 4.611 4.611 0 0 0 1.949 3.783 2.575 2.575 0 0 0 1.542.428l.034-.002.084-.006.033-.004.087-.011.02-.003c1.053-.163 1.97-.986 2.269-2.01v1.85c0 .105.084.19.188.19h1.614a.189.189 0 0 0 .188-.19V3.541a.189.189 0 0 0-.188-.189h-1.802Zm0 6.62c-.39.326-.895.448-1.435.484l-.016.002a1.675 1.675 0 0 1-.107.003c-1.352 0-2.468-1.149-2.468-2.54 0-.328.063-.64.174-.927.359-.932 1.24-1.591 2.273-1.591h1.579v4.57ZM42.775 3.352h-1.578c-1.547 0-2.909.815-3.704 2.051a4.63 4.63 0 0 0-.735 2.519 4.6 4.6 0 0 0 1.65 3.555c.094.083.194.16.298.228a2.575 2.575 0 0 0 2.966-.08c.52-.37.924-.913 1.103-1.527v1.608h-.004v.354c0 .7-.182 1.225-.554 1.58-.372.354-.994.532-1.864.532-.356 0-.921-.02-1.491-.078a.19.19 0 0 0-.2.136l-.41 1.379a.19.19 0 0 0 .155.24c.688.1 1.36.15 1.748.15 1.565 0 2.725-.343 3.484-1.03.688-.621 1.061-1.564 1.127-2.832V3.54a.189.189 0 0 0-.19-.189h-1.801Zm0 2.051s.021 4.452 0 4.587c-.386.312-.867.435-1.391.47l-.016.001a1.751 1.751 0 0 1-.233 0c-1.293-.067-2.385-1.192-2.385-2.54 0-.327.063-.64.174-.927.359-.931 1.24-1.591 2.273-1.591h1.578Z"
clipRule="evenodd"
/>
<path d="M8.725.001C4.356.001.795 3.523.732 7.877c-.064 4.422 3.524 8.085 7.946 8.111a7.94 7.94 0 0 0 3.849-.96.187.187 0 0 0 .034-.305l-.748-.663a.528.528 0 0 0-.555-.094 6.461 6.461 0 0 1-2.614.513c-3.574-.043-6.46-3.016-6.404-6.59a6.493 6.493 0 0 1 6.485-6.38h6.485v11.527l-3.68-3.269a.271.271 0 0 0-.397.042 3.014 3.014 0 0 1-5.416-1.583 3.02 3.02 0 0 1 3.008-3.248 3.02 3.02 0 0 1 3.005 2.75.537.537 0 0 0 .176.356l.958.85a.187.187 0 0 0 .308-.106c.07-.37.094-.755.067-1.15a4.536 4.536 0 0 0-4.23-4.2A4.53 4.53 0 0 0 4.203 7.87c-.067 2.467 1.954 4.593 4.421 4.648a4.498 4.498 0 0 0 2.756-.863l4.808 4.262a.32.32 0 0 0 .531-.239V.304a.304.304 0 0 0-.303-.303h-7.69Z" />
</svg>
)
}
function SearchButton(props) {
let [modifierKey, setModifierKey] = useState()
useEffect(() => {
setModifierKey(
/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? '⌘' : 'Ctrl '
)
}, [])
return (
<>
<button
type="button"
className="hidden h-8 w-full items-center gap-2 rounded-full bg-white pl-2 pr-3 text-sm text-zinc-500 ring-1 ring-zinc-900/10 transition hover:ring-zinc-900/20 dark:bg-white/5 dark:text-zinc-400 dark:ring-inset dark:ring-white/10 dark:hover:ring-white/20 lg:flex focus:[&:not(:focus-visible)]:outline-none"
{...props}
>
<SearchIcon className="h-5 w-5 stroke-current" />
Find something...
<kbd className="ml-auto text-2xs text-zinc-400 dark:text-zinc-500">
<kbd className="font-sans">{modifierKey}</kbd>
<kbd className="font-sans">K</kbd>
</kbd>
</button>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5 lg:hidden focus:[&:not(:focus-visible)]:outline-none"
aria-label="Find something..."
{...props}
>
<SearchIcon className="h-5 w-5 stroke-zinc-900 dark:stroke-white" />
</button>
</>
)
}
function SearchDialog({ open, setOpen, className }) {
let router = useRouter()
let formRef = useRef()
let panelRef = useRef()
let inputRef = useRef()
let { autocomplete, autocompleteState } = useAutocomplete()
useEffect(() => {
if (!open) {
return
}
function onRouteChange() {
setOpen(false)
}
router.events.on('routeChangeStart', onRouteChange)
router.events.on('hashChangeStart', onRouteChange)
return () => {
router.events.off('routeChangeStart', onRouteChange)
router.events.off('hashChangeStart', onRouteChange)
}
}, [open, setOpen, router])
useEffect(() => {
if (open) {
return
}
function onKeyDown(event) {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [open, setOpen])
return (
<Transition.Root
show={open}
as={Fragment}
afterLeave={() => autocomplete.setQuery('')}
>
<Dialog
onClose={setOpen}
className={clsx('fixed inset-0 z-50', className)}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-zinc-400/25 backdrop-blur-sm dark:bg-black/40" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:py-20 sm:px-6 md:py-32 lg:px-8 lg:py-[15vh]">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="mx-auto overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-1 ring-zinc-900/7.5 dark:bg-zinc-900 dark:ring-zinc-800 sm:max-w-xl">
<div {...autocomplete.getRootProps({})}>
<form
ref={formRef}
{...autocomplete.getFormProps({
inputElement: inputRef.current,
})}
>
<SearchInput
ref={inputRef}
autocomplete={autocomplete}
autocompleteState={autocompleteState}
onClose={() => setOpen(false)}
/>
<div
ref={panelRef}
className="border-t border-zinc-200 bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
{...autocomplete.getPanelProps({})}
>
{autocompleteState.isOpen && (
<>
<SearchResults
autocomplete={autocomplete}
query={autocompleteState.query}
collection={autocompleteState.collections[0]}
/>
<p className="flex items-center justify-end gap-2 border-t border-zinc-100 px-4 py-2 text-xs text-zinc-400 dark:border-zinc-800 dark:text-zinc-500">
Search by{' '}
<AlgoliaLogo className="h-4 fill-[#003DFF] dark:fill-zinc-400" />
</p>
</>
)}
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
)
}
function useSearchProps() {
let buttonRef = useRef()
let [open, setOpen] = useState(false)
return {
buttonProps: {
ref: buttonRef,
onClick() {
setOpen(true)
},
},
dialogProps: {
open,
setOpen(open) {
let { width, height } = buttonRef.current.getBoundingClientRect()
if (!open || (width !== 0 && height !== 0)) {
setOpen(open)
}
},
},
}
}
export function Search() {
let [modifierKey, setModifierKey] = useState()
let { buttonProps, dialogProps } = useSearchProps()
useEffect(() => {
setModifierKey(
/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? '⌘' : 'Ctrl '
)
}, [])
return (
<div className="hidden lg:block lg:max-w-md lg:flex-auto">
<button
type="button"
className="hidden h-8 w-full items-center gap-2 rounded-full bg-white pl-2 pr-3 text-sm text-zinc-500 ring-1 ring-zinc-900/10 transition hover:ring-zinc-900/20 dark:bg-white/5 dark:text-zinc-400 dark:ring-inset dark:ring-white/10 dark:hover:ring-white/20 lg:flex focus:[&:not(:focus-visible)]:outline-none"
{...buttonProps}
>
<SearchIcon className="h-5 w-5 stroke-current" />
Find something...
<kbd className="ml-auto text-2xs text-zinc-400 dark:text-zinc-500">
<kbd className="font-sans">{modifierKey}</kbd>
<kbd className="font-sans">K</kbd>
</kbd>
</button>
<SearchDialog className="hidden lg:block" {...dialogProps} />
</div>
)
}
export function MobileSearch() {
let { buttonProps, dialogProps } = useSearchProps()
return (
<div className="contents lg:hidden">
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5 lg:hidden focus:[&:not(:focus-visible)]:outline-none"
aria-label="Find something..."
{...buttonProps}
>
<SearchIcon className="h-5 w-5 stroke-zinc-900 dark:stroke-white" />
</button>
<SearchDialog className="lg:hidden" {...dialogProps} />
</div>
)
}

View File

@ -0,0 +1,117 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react'
import { createStore, useStore } from 'zustand'
import { remToPx } from '@/lib/remToPx'
function createSectionStore(sections) {
return createStore((set) => ({
sections,
visibleSections: [],
setVisibleSections: (visibleSections) =>
set((state) =>
state.visibleSections.join() === visibleSections.join()
? {}
: { visibleSections }
),
registerHeading: ({ id, ref, offsetRem }) =>
set((state) => {
return {
sections: state.sections.map((section) => {
if (section.id === id) {
return {
...section,
headingRef: ref,
offsetRem,
}
}
return section
}),
}
}),
}))
}
function useVisibleSections(sectionStore) {
let setVisibleSections = useStore(sectionStore, (s) => s.setVisibleSections)
let sections = useStore(sectionStore, (s) => s.sections)
useEffect(() => {
function checkVisibleSections() {
let { innerHeight, scrollY } = window
let newVisibleSections = []
for (
let sectionIndex = 0;
sectionIndex < sections.length;
sectionIndex++
) {
let { id, headingRef, offsetRem } = sections[sectionIndex]
let offset = remToPx(offsetRem)
let top = headingRef.current.getBoundingClientRect().top + scrollY
if (sectionIndex === 0 && top - offset > scrollY) {
newVisibleSections.push('_top')
}
let nextSection = sections[sectionIndex + 1]
let bottom =
(nextSection?.headingRef.current.getBoundingClientRect().top ??
Infinity) +
scrollY -
remToPx(nextSection?.offsetRem ?? 0)
if (
(top > scrollY && top < scrollY + innerHeight) ||
(bottom > scrollY && bottom < scrollY + innerHeight) ||
(top <= scrollY && bottom >= scrollY + innerHeight)
) {
newVisibleSections.push(id)
}
}
setVisibleSections(newVisibleSections)
}
let raf = window.requestAnimationFrame(() => checkVisibleSections())
window.addEventListener('scroll', checkVisibleSections, { passive: true })
window.addEventListener('resize', checkVisibleSections)
return () => {
window.cancelAnimationFrame(raf)
window.removeEventListener('scroll', checkVisibleSections)
window.removeEventListener('resize', checkVisibleSections)
}
}, [setVisibleSections, sections])
}
const SectionStoreContext = createContext()
const useIsomorphicLayoutEffect =
typeof window === 'undefined' ? useEffect : useLayoutEffect
export function SectionProvider({ sections, children }) {
let [sectionStore] = useState(() => createSectionStore(sections))
useVisibleSections(sectionStore)
useIsomorphicLayoutEffect(() => {
sectionStore.setState({ sections })
}, [sectionStore, sections])
return (
<SectionStoreContext.Provider value={sectionStore}>
{children}
</SectionStoreContext.Provider>
)
}
export function useSectionStore(selector) {
let store = useContext(SectionStoreContext)
return useStore(store, selector)
}

View File

@ -0,0 +1,58 @@
import clsx from 'clsx'
const variantStyles = {
medium: 'rounded-lg px-1.5 ring-1 ring-inset',
}
const colorStyles = {
blue: {
small: 'text-blue-500 dark:text-blue-400',
medium:
'ring-blue-300 dark:ring-blue-400/30 bg-blue-400/10 text-blue-500 dark:text-blue-400',
},
sky: {
small: 'text-sky-500',
medium:
'ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400',
},
amber: {
small: 'text-amber-500',
medium:
'ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400',
},
rose: {
small: 'text-red-500 dark:text-rose-500',
medium:
'ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400',
},
zinc: {
small: 'text-zinc-400 dark:text-zinc-500',
medium:
'ring-zinc-200 bg-zinc-50 text-zinc-500 dark:ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-400',
},
}
const valueColorMap = {
get: 'blue',
post: 'sky',
put: 'amber',
delete: 'rose',
}
export function Tag({
children,
variant = 'medium',
color = valueColorMap[children.toLowerCase()] ?? 'blue',
}) {
return (
<span
className={clsx(
'font-mono text-[0.625rem] font-semibold leading-6',
variantStyles[variant],
colorStyles[color][variant]
)}
>
{children}
</span>
)
}

View File

@ -0,0 +1,17 @@
export function BellIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.438 8.063a5.563 5.563 0 0 1 11.125 0v2.626c0 1.182.34 2.34.982 3.332L17.5 15.5h-15l.955-1.479c.641-.993.982-2.15.982-3.332V8.062Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 15.5v0a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v0"
/>
</svg>
)
}

View File

@ -0,0 +1,11 @@
export function BoltIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 11.5 10 2v5.5a1 1 0 0 0 1 1h4.5L10 18v-5.5a1 1 0 0 0-1-1H4.5Z"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function BookIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="m10 5.5-7.5-3v12l7.5 3m0-12 7.5-3v12l-7.5 3m0-12v12"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m17.5 2.5-7.5 3v12l7.5-3v-12Z"
/>
</svg>
)
}

View File

@ -0,0 +1,23 @@
export function CalendarIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-9Z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v2h-15v-2Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M5.5 5.5v-3M14.5 5.5v-3"
/>
</svg>
)
}

View File

@ -0,0 +1,15 @@
export function CartIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeWidth="0"
d="M5.98 11.288 3.5 5.5h14l-2.48 5.788A2 2 0 0 1 13.18 12.5H7.82a2 2 0 0 1-1.838-1.212Z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m3.5 5.5 2.48 5.788A2 2 0 0 0 7.82 12.5h5.362a2 2 0 0 0 1.839-1.212L17.5 5.5h-14Zm0 0-1-2M6.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM14.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function ChatBubbleIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 16.5c4.142 0 7.5-3.134 7.5-7s-3.358-7-7.5-7c-4.142 0-7.5 3.134-7.5 7 0 1.941.846 3.698 2.214 4.966L3.5 17.5c2.231 0 3.633-.553 4.513-1.248A8.014 8.014 0 0 0 10 16.5Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 8.5h5M8.5 11.5h3"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function CheckIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="m7.5 10.5 2 2c1-3.5 3-5 3-5"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function ChevronRightLeftIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M1.5 10A6.5 6.5 0 0 1 8 3.5h4a6.5 6.5 0 1 1 0 13H8A6.5 6.5 0 0 1 1.5 10Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="m7.5 7.5-3 2.5 3 2.5M12.5 7.5l3 2.5-3 2.5"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function ClipboardIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M3.5 6v10a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1l-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4h-1a2 2 0 0 0-2 2Z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m13.5 4-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4l.724-1.447A1 1 0 0 1 8.118 2h3.764a1 1 0 0 1 .894.553L13.5 4Z"
/>
</svg>
)
}

View File

@ -0,0 +1,19 @@
export function CogIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeWidth="0"
fillRule="evenodd"
d="M11.063 1.5H8.937l-.14 1.128c-.086.682-.61 1.22-1.246 1.484-.634.264-1.37.247-1.912-.175l-.898-.699-1.503 1.503.699.898c.422.543.44 1.278.175 1.912-.264.635-.802 1.16-1.484 1.245L1.5 8.938v2.124l1.128.142c.682.085 1.22.61 1.484 1.244.264.635.247 1.37-.175 1.913l-.699.898 1.503 1.503.898-.699c.543-.422 1.278-.44 1.912-.175.635.264 1.16.801 1.245 1.484l.142 1.128h2.124l.142-1.128c.085-.683.61-1.22 1.244-1.484.635-.264 1.37-.247 1.913.175l.898.699 1.503-1.503-.699-.898c-.422-.543-.44-1.278-.175-1.913.264-.634.801-1.16 1.484-1.245l1.128-.14V8.937l-1.128-.14c-.683-.086-1.22-.611-1.484-1.246-.264-.634-.247-1.37.175-1.912l.699-.898-1.503-1.503-.898.699c-.543.422-1.278.44-1.913.175-.634-.264-1.16-.802-1.244-1.484L11.062 1.5ZM10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
clipRule="evenodd"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M8.938 1.5h2.124l.142 1.128c.085.682.61 1.22 1.244 1.484v0c.635.264 1.37.247 1.913-.175l.898-.699 1.503 1.503-.699.898c-.422.543-.44 1.278-.175 1.912v0c.264.635.801 1.16 1.484 1.245l1.128.142v2.124l-1.128.142c-.683.085-1.22.61-1.484 1.244v0c-.264.635-.247 1.37.175 1.913l.699.898-1.503 1.503-.898-.699c-.543-.422-1.278-.44-1.913-.175v0c-.634.264-1.16.801-1.245 1.484l-.14 1.128H8.937l-.14-1.128c-.086-.683-.611-1.22-1.246-1.484v0c-.634-.264-1.37-.247-1.912.175l-.898.699-1.503-1.503.699-.898c.422-.543.44-1.278.175-1.913v0c-.264-.634-.802-1.16-1.484-1.245l-1.128-.14V8.937l1.128-.14c.682-.086 1.22-.61 1.484-1.246v0c.264-.634.247-1.37-.175-1.912l-.699-.898 1.503-1.503.898.699c.543.422 1.278.44 1.912.175v0c.635-.264 1.16-.802 1.245-1.484L8.938 1.5Z"
/>
<circle cx="10" cy="10" r="2.5" fill="none" />
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function CopyIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M14.5 5.5v-1a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.5 7.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8Z"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function DocumentIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.5 4.5v11a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-8h-5v-5h-6a2 2 0 0 0-2 2Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="m11.5 2.5 5 5"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function EnvelopeIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M2.5 5.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v8a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-8Z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 10 4.526 5.256c-.7-.607-.271-1.756.655-1.756h9.638c.926 0 1.355 1.15.655 1.756L10 10Z"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function FaceSmileIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 6.5v2M12.5 6.5v2M5.5 11.5s1 3 4.5 3 4.5-3 4.5-3"
/>
</svg>
)
}

View File

@ -0,0 +1,22 @@
export function FolderIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M17.5 15.5v-8a2 2 0 0 0-2-2h-2.93a2 2 0 0 1-1.664-.89l-.812-1.22A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2Z"
/>
<path
strokeWidth="0"
d="M8.43 2.5H4.5a2 2 0 0 0-2 2v1h9l-1.406-2.11A2 2 0 0 0 8.43 2.5Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="m11.5 5.5-1.406-2.11A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v1h9Zm0 0h2"
/>
</svg>
)
}

View File

@ -0,0 +1,12 @@
export function LinkIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="m5.056 11.5-1.221-1.222a4.556 4.556 0 0 1 6.443-6.443L11.5 5.056M7.5 7.5l5 5m2.444-4 1.222 1.222a4.556 4.556 0 0 1-6.444 6.444L8.5 14.944"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function ListIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.5 4.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-11Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M6.5 6.5h7M6.5 13.5h7M6.5 10h7"
/>
</svg>
)
}

View File

@ -0,0 +1,13 @@
export function MagnifyingGlassIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path strokeWidth="0" d="M2.5 8.5a6 6 0 1 1 12 0 6 6 0 0 1-12 0Z" />
<path
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="m13 13 4.5 4.5m-9-3a6 6 0 1 1 0-12 6 6 0 0 1 0 12Z"
/>
</svg>
)
}

View File

@ -0,0 +1,19 @@
export function MapPinIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeWidth="0"
fillRule="evenodd"
clipRule="evenodd"
d="M10 2.5A5.5 5.5 0 0 0 4.5 8c0 3.038 5.5 9.5 5.5 9.5s5.5-6.462 5.5-9.5A5.5 5.5 0 0 0 10 2.5Zm0 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 8a5.5 5.5 0 1 1 11 0c0 3.038-5.5 9.5-5.5 9.5S4.5 11.038 4.5 8Z"
/>
<circle cx="10" cy="8" r="1.5" fill="none" />
</svg>
)
}

View File

@ -0,0 +1,16 @@
export function PackageIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
strokeWidth="0"
d="m10 9.5-7.5-4v9l7.5 4v-9ZM10 9.5l7.5-4v9l-7.5 4v-9Z"
/>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="m2.5 5.5 7.5 4m-7.5-4v9l7.5 4m-7.5-13 7.5-4 7.5 4m-7.5 4v9m0-9 7.5-4m-7.5 13 7.5-4v-9m-11 6 .028-3.852L13.5 3.5"
/>
</svg>
)
}

View File

@ -0,0 +1,17 @@
export function PaperAirplaneIcon(props) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
d="M17 3L1 9L8 12M17 3L11 19L8 12M17 3L8 12"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11 19L8 12L17 3L11 19Z"
/>
</svg>
)
}

Some files were not shown because too many files have changed in this diff Show More