forked from github/plane
Merge branch 'stage-release' of https://github.com/makeplane/plane into stage-release
This commit is contained in:
commit
054fbab887
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 = [
|
||||
|
@ -44,7 +44,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
.prefetch_related("members")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"module_issues",
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
)
|
||||
)
|
||||
|
24
apiserver/plane/db/migrations/0014_auto_20221222_1436.py
Normal file
24
apiserver/plane/db/migrations/0014_auto_20221222_1436.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
230
apps/app/components/common/bulk-delete-issues-modal.tsx
Normal file
230
apps/app/components/common/bulk-delete-issues-modal.tsx
Normal 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;
|
@ -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));
|
||||
handleClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
handleOnSubmit(data);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
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;
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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 ? (
|
||||
|
@ -79,7 +79,7 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
|
||||
});
|
||||
});
|
||||
|
||||
// handleClose();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const removeBlocked = (issueId: string) => {
|
||||
|
96
apps/app/components/project/modules/board-view/index.tsx
Normal file
96
apps/app/components/project/modules/board-view/index.tsx
Normal 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;
|
208
apps/app/components/project/modules/board-view/single-board.tsx
Normal file
208
apps/app/components/project/modules/board-view/single-board.tsx
Normal 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;
|
147
apps/app/components/project/modules/confirm-module-deleteion.tsx
Normal file
147
apps/app/components/project/modules/confirm-module-deleteion.tsx
Normal 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;
|
@ -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">
|
@ -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;
|
@ -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;
|
@ -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;
|
328
apps/app/components/project/modules/list-view/index.tsx
Normal file
328
apps/app/components/project/modules/list-view/index.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
102
apps/app/components/project/modules/single-module-card.tsx
Normal file
102
apps/app/components/project/modules/single-module-card.tsx
Normal 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;
|
@ -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}/`;
|
||||
|
@ -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";
|
||||
}
|
||||
};
|
||||
|
@ -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" },
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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,23 +390,22 @@ const SingleCycle: React.FC = () => {
|
||||
openCreateIssueModal={openCreateIssueModal}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
handleDeleteIssue={setDeleteIssue}
|
||||
setPreloadedData={setPreloadedData}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-screen">
|
||||
<CyclesBoardView
|
||||
groupedByIssues={groupedByIssues}
|
||||
properties={properties}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
selectedGroup={groupByProperty}
|
||||
members={members}
|
||||
openCreateIssueModal={openCreateIssueModal}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
handleDeleteIssue={setDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
setPreloadedData={setPreloadedData}
|
||||
/>
|
||||
</div>
|
||||
<CyclesBoardView
|
||||
groupedByIssues={groupedByIssues}
|
||||
properties={properties}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
selectedGroup={groupByProperty}
|
||||
members={members}
|
||||
openCreateIssueModal={openCreateIssueModal}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
handleDeleteIssue={setDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
setPreloadedData={setPreloadedData}
|
||||
/>
|
||||
)}
|
||||
</AppLayout>
|
||||
</>
|
||||
|
@ -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">
|
||||
|
407
apps/app/pages/projects/[projectId]/modules/[moduleId].tsx
Normal file
407
apps/app/pages/projects/[projectId]/modules/[moduleId].tsx
Normal 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;
|
@ -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,12 +64,11 @@ const ProjectModules: NextPage = () => {
|
||||
{modules ? (
|
||||
modules.length > 0 ? (
|
||||
<div className="space-y-5">
|
||||
{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>
|
||||
))}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{modules.map((module) => (
|
||||
<SingleModuleCard key={module.id} module={module} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col justify-center items-center px-4">
|
||||
|
@ -119,150 +119,160 @@ 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>
|
||||
<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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_lead"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">
|
||||
{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" />
|
||||
</Listbox.Button>
|
||||
<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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_lead"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">
|
||||
{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"
|
||||
/>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-default select-none relative px-3 py-2`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-default select-none relative px-3 py-2`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-theme"
|
||||
}`}
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
{person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-theme"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-md leading-6 text-gray-900 mb-1">Default Assignee</h4>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Select the default assignee for the project.
|
||||
</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="default_assignee"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">
|
||||
{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" />
|
||||
</Listbox.Button>
|
||||
<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">
|
||||
Select the default assignee for the project.
|
||||
</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="default_assignee"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">
|
||||
{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"
|
||||
/>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-default select-none relative px-3 py-2`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-default select-none relative px-3 py-2`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-theme"
|
||||
}`}
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
{person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-theme"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -136,97 +136,101 @@ const GeneralSettings = () => {
|
||||
This information will be displayed to every member of the project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-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">
|
||||
Select an icon and a name for the project.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="icon"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<EmojiIconPicker
|
||||
label={value ? String.fromCodePoint(parseInt(value)) : "Icon"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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">
|
||||
Select an icon and a name for the project.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="icon"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<EmojiIconPicker
|
||||
label={value ? String.fromCodePoint(parseInt(value)) : "Icon"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
placeholder="Project Name"
|
||||
size="lg"
|
||||
className="w-auto"
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-md leading-6 text-gray-900 mb-1">Identifier</h4>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Create a 1-6 characters{"'"} identifier for the project.
|
||||
</p>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
error={errors.name}
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
error={errors.identifier}
|
||||
register={register}
|
||||
placeholder="Project Name"
|
||||
placeholder="Enter identifier"
|
||||
className="w-40"
|
||||
size="lg"
|
||||
className="w-auto"
|
||||
onChange={(e: any) => {
|
||||
if (!activeWorkspace || !e.target.value) return;
|
||||
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
|
||||
}}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
required: "Identifier is required",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Identifier must at least be of 1 character",
|
||||
},
|
||||
maxLength: {
|
||||
value: 9,
|
||||
message: "Identifier must at most be of 9 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
Create a 1-6 characters{"'"} identifier for the project.
|
||||
</p>
|
||||
<Input
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
error={errors.identifier}
|
||||
register={register}
|
||||
placeholder="Enter identifier"
|
||||
className="w-40"
|
||||
size="lg"
|
||||
onChange={(e: any) => {
|
||||
if (!activeWorkspace || !e.target.value) return;
|
||||
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
|
||||
}}
|
||||
validations={{
|
||||
required: "Identifier is required",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Identifier must at least be of 1 character",
|
||||
},
|
||||
maxLength: {
|
||||
value: 9,
|
||||
message: "Identifier must at most be of 9 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<Select
|
||||
name="network"
|
||||
id="network"
|
||||
options={Object.keys(NETWORK_CHOICES).map((key) => ({
|
||||
value: key,
|
||||
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
|
||||
}))}
|
||||
size="lg"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Network is required",
|
||||
}}
|
||||
className="w-40"
|
||||
/>
|
||||
<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>
|
||||
<Select
|
||||
name="network"
|
||||
id="network"
|
||||
options={Object.keys(NETWORK_CHOICES).map((key) => ({
|
||||
value: key,
|
||||
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
|
||||
}))}
|
||||
size="lg"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Network is required",
|
||||
}}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
2
apps/app/types/cycles.d.ts
vendored
2
apps/app/types/cycles.d.ts
vendored
@ -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;
|
||||
|
24
apps/app/types/modules.d.ts
vendored
24
apps/app/types/modules.d.ts
vendored
@ -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;
|
||||
|
@ -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,42 @@ 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 ${
|
||||
<Listbox.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 text-xs duration-300 ${
|
||||
width === "sm"
|
||||
? "w-32"
|
||||
: width === "md"
|
||||
? "w-48"
|
||||
: width === "lg"
|
||||
? "w-64"
|
||||
: width === "xl"
|
||||
? "w-80"
|
||||
: width === "2xl"
|
||||
? "w-96"
|
||||
: width === "w-full"
|
||||
? "w-full"
|
||||
: ""
|
||||
}
|
||||
${className || "px-2 py-1"}`}
|
||||
>
|
||||
{icon ?? null}
|
||||
<span className="block truncate">
|
||||
{Array.isArray(value)
|
||||
? value.map((v) => options?.find((o) => o.value === v)?.display).join(", ") ||
|
||||
`${title}`
|
||||
: options?.find((o) => o.value === value)?.display || `${title}`}
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={`absolute mt-1 bg-white shadow-lg max-h-32 overflow-auto ${
|
||||
width === "sm"
|
||||
? "w-32"
|
||||
: width === "md"
|
||||
@ -44,122 +77,87 @@ const CustomListbox: React.FC<Props> = ({
|
||||
: width === "w-full"
|
||||
? "w-full"
|
||||
: ""
|
||||
}
|
||||
${className || "px-2 py-1"}`}
|
||||
} ${
|
||||
optionsFontsize === "sm"
|
||||
? "text-xs"
|
||||
: optionsFontsize === "md"
|
||||
? "text-base"
|
||||
: optionsFontsize === "lg"
|
||||
? "text-lg"
|
||||
: optionsFontsize === "xl"
|
||||
? "text-xl"
|
||||
: optionsFontsize === "2xl"
|
||||
? "text-2xl"
|
||||
: ""
|
||||
} rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10`}
|
||||
>
|
||||
{icon ?? null}
|
||||
<span className="block truncate text-xs">
|
||||
{Array.isArray(value)
|
||||
? value.map((v) => options?.find((o) => o.value === v)?.display).join(", ") ||
|
||||
`${title}`
|
||||
: options?.find((o) => o.value === value)?.display || `${title}`}
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<div className="py-1">
|
||||
{options ? (
|
||||
options.length > 0 ? (
|
||||
options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.value}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-pointer select-none relative p-2`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value)
|
||||
? "font-semibold"
|
||||
: "font-normal"
|
||||
} flex items-center gap-2 truncate`}
|
||||
>
|
||||
{option.color && (
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: option.color,
|
||||
}}
|
||||
></span>
|
||||
)}
|
||||
{option.display}
|
||||
</span>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={`absolute mt-1 bg-white shadow-lg max-h-32 overflow-auto ${
|
||||
width === "sm"
|
||||
? "w-32"
|
||||
: width === "md"
|
||||
? "w-48"
|
||||
: width === "lg"
|
||||
? "w-64"
|
||||
: width === "xl"
|
||||
? "w-80"
|
||||
: width === "2xl"
|
||||
? "w-96"
|
||||
: width === "w-full"
|
||||
? "w-full"
|
||||
: ""
|
||||
} ${
|
||||
optionsFontsize === "sm"
|
||||
? "text-xs"
|
||||
: optionsFontsize === "md"
|
||||
? "text-base"
|
||||
: optionsFontsize === "lg"
|
||||
? "text-lg"
|
||||
: optionsFontsize === "xl"
|
||||
? "text-xl"
|
||||
: optionsFontsize === "2xl"
|
||||
? "text-2xl"
|
||||
: ""
|
||||
} rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10`}
|
||||
>
|
||||
<div className="py-1">
|
||||
{options ? (
|
||||
options.length > 0 ? (
|
||||
options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.value}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-pointer select-none relative p-2`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
{selected ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value) ? (
|
||||
<span
|
||||
className={`${
|
||||
selected ||
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value)
|
||||
? "font-semibold"
|
||||
: "font-normal"
|
||||
} flex items-center gap-2 truncate`}
|
||||
? "text-white"
|
||||
: "text-theme"
|
||||
}`}
|
||||
>
|
||||
{option.color && (
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: option.color,
|
||||
}}
|
||||
></span>
|
||||
)}
|
||||
{option.display}
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
{selected ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value) ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value)
|
||||
? "text-white"
|
||||
: "text-theme"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center">No options</p>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
{footerOption ?? null}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-center">No options</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
{footerOption ?? null}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
|
@ -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
|
@ -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";
|
||||
|
@ -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
3
apps/docs/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Plane Docs
|
||||
|
||||
Source code that powers plane.so/docs
|
8
apps/docs/jsconfig.json
Normal file
8
apps/docs/jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
19
apps/docs/mdx/recma.mjs
Normal file
19
apps/docs/mdx/recma.mjs
Normal 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
126
apps/docs/mdx/rehype.mjs
Normal 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
3
apps/docs/mdx/remark.mjs
Normal file
@ -0,0 +1,3 @@
|
||||
import { mdxAnnotations } from 'mdx-annotations'
|
||||
|
||||
export const remarkPlugins = [mdxAnnotations.remark]
|
23
apps/docs/next.config.mjs
Normal file
23
apps/docs/next.config.mjs
Normal 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
46
apps/docs/package.json
Normal 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"
|
||||
}
|
||||
}
|
9
apps/docs/postcss.config.js
Normal file
9
apps/docs/postcss.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'postcss-focus-visible': {
|
||||
replaceWith: '[data-focus-visible-added]',
|
||||
},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
5
apps/docs/prettier.config.js
Normal file
5
apps/docs/prettier.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
plugins: [require('prettier-plugin-tailwindcss')],
|
||||
}
|
BIN
apps/docs/public/favicon.ico
Normal file
BIN
apps/docs/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
62
apps/docs/src/components/Button.jsx
Normal file
62
apps/docs/src/components/Button.jsx
Normal 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>
|
||||
)
|
||||
}
|
297
apps/docs/src/components/Code.jsx
Normal file
297
apps/docs/src/components/Code.jsx
Normal 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>
|
||||
}
|
228
apps/docs/src/components/Footer.jsx
Normal file
228
apps/docs/src/components/Footer.jsx
Normal 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">
|
||||
© 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>
|
||||
)
|
||||
}
|
42
apps/docs/src/components/GridPattern.jsx
Normal file
42
apps/docs/src/components/GridPattern.jsx
Normal 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>
|
||||
)
|
||||
}
|
54
apps/docs/src/components/Guides.jsx
Normal file
54
apps/docs/src/components/Guides.jsx
Normal 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>
|
||||
)
|
||||
}
|
87
apps/docs/src/components/Header.jsx
Normal file
87
apps/docs/src/components/Header.jsx
Normal 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>
|
||||
)
|
||||
})
|
102
apps/docs/src/components/Heading.jsx
Normal file
102
apps/docs/src/components/Heading.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
32
apps/docs/src/components/HeroPattern.jsx
Normal file
32
apps/docs/src/components/HeroPattern.jsx
Normal 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>
|
||||
)
|
||||
}
|
41
apps/docs/src/components/Layout.jsx
Normal file
41
apps/docs/src/components/Layout.jsx
Normal 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>
|
||||
)
|
||||
}
|
82
apps/docs/src/components/Libraries.jsx
Normal file
82
apps/docs/src/components/Libraries.jsx
Normal 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>
|
||||
)
|
||||
}
|
5
apps/docs/src/components/Logo.jsx
Normal file
5
apps/docs/src/components/Logo.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
export function Logo() {
|
||||
return (
|
||||
<img src="https://camo.githubusercontent.com/ef32512ae486a8cef8e000e74b2ff11c92c89c2cadb2d79674c6bd1599b99a56/68747470733a2f2f696b2e696d6167656b69742e696f2f77326f6b77627475322f706c616e652d6c6f676f5f306d383378756537522e706e673f696b2d73646b2d76657273696f6e3d6a6176617363726970742d312e342e33267570646174656441743d31363638383632373137303834" height={100} width={100} alt="Plane"/>
|
||||
)
|
||||
}
|
115
apps/docs/src/components/MobileNavigation.jsx
Normal file
115
apps/docs/src/components/MobileNavigation.jsx
Normal 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>
|
||||
)
|
||||
}
|
54
apps/docs/src/components/ModeToggle.jsx
Normal file
54
apps/docs/src/components/ModeToggle.jsx
Normal 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>
|
||||
)
|
||||
}
|
236
apps/docs/src/components/Navigation.jsx
Normal file
236
apps/docs/src/components/Navigation.jsx
Normal 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>
|
||||
)
|
||||
}
|
10
apps/docs/src/components/Prose.jsx
Normal file
10
apps/docs/src/components/Prose.jsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
157
apps/docs/src/components/Resources.jsx
Normal file
157
apps/docs/src/components/Resources.jsx
Normal 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>
|
||||
)
|
||||
}
|
515
apps/docs/src/components/Search.jsx
Normal file
515
apps/docs/src/components/Search.jsx
Normal 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">
|
||||
‘{query}’
|
||||
</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>
|
||||
)
|
||||
}
|
117
apps/docs/src/components/SectionProvider.jsx
Normal file
117
apps/docs/src/components/SectionProvider.jsx
Normal 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)
|
||||
}
|
58
apps/docs/src/components/Tag.jsx
Normal file
58
apps/docs/src/components/Tag.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/BellIcon.jsx
Normal file
17
apps/docs/src/components/icons/BellIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
11
apps/docs/src/components/icons/BoltIcon.jsx
Normal file
11
apps/docs/src/components/icons/BoltIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/BookIcon.jsx
Normal file
17
apps/docs/src/components/icons/BookIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
23
apps/docs/src/components/icons/CalendarIcon.jsx
Normal file
23
apps/docs/src/components/icons/CalendarIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
15
apps/docs/src/components/icons/CartIcon.jsx
Normal file
15
apps/docs/src/components/icons/CartIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/ChatBubbleIcon.jsx
Normal file
17
apps/docs/src/components/icons/ChatBubbleIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/CheckIcon.jsx
Normal file
17
apps/docs/src/components/icons/CheckIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/ChevronRightLeftIcon.jsx
Normal file
17
apps/docs/src/components/icons/ChevronRightLeftIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/ClipboardIcon.jsx
Normal file
17
apps/docs/src/components/icons/ClipboardIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
19
apps/docs/src/components/icons/CogIcon.jsx
Normal file
19
apps/docs/src/components/icons/CogIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/CopyIcon.jsx
Normal file
17
apps/docs/src/components/icons/CopyIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/DocumentIcon.jsx
Normal file
17
apps/docs/src/components/icons/DocumentIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/EnvelopeIcon.jsx
Normal file
17
apps/docs/src/components/icons/EnvelopeIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/FaceSmileIcon.jsx
Normal file
17
apps/docs/src/components/icons/FaceSmileIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
22
apps/docs/src/components/icons/FolderIcon.jsx
Normal file
22
apps/docs/src/components/icons/FolderIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
12
apps/docs/src/components/icons/LinkIcon.jsx
Normal file
12
apps/docs/src/components/icons/LinkIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/ListIcon.jsx
Normal file
17
apps/docs/src/components/icons/ListIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
13
apps/docs/src/components/icons/MagnifyingGlassIcon.jsx
Normal file
13
apps/docs/src/components/icons/MagnifyingGlassIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
19
apps/docs/src/components/icons/MapPinIcon.jsx
Normal file
19
apps/docs/src/components/icons/MapPinIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
16
apps/docs/src/components/icons/PackageIcon.jsx
Normal file
16
apps/docs/src/components/icons/PackageIcon.jsx
Normal 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>
|
||||
)
|
||||
}
|
17
apps/docs/src/components/icons/PaperAirplaneIcon.jsx
Normal file
17
apps/docs/src/components/icons/PaperAirplaneIcon.jsx
Normal 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
Loading…
Reference in New Issue
Block a user