[WEB-3292] feat: workspace switcher redesign (#6543)
* feat: ui changes for workspace switcher * fix: hover * fix: added current plan * feat: Return user role * chore: remove unused imports * fix: css * fix: added user role in workspace switcher * fix: return role as integer * fix: role casing * fix: refactor * fix: plan pill fix * fix: design updates * fix: refactor * fix: member translation * fix: css improvements * fix: truncate issue * fix: workspace switcher dropdown email truncate * fix: workspace switcher dropdown email truncate * fix: role --------- Co-authored-by: sangeethailango <sangeethailango21@gmail.com> Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
This commit is contained in:
parent
456c7f55a9
commit
20ba91b98c
7 changed files with 172 additions and 112 deletions
|
|
@ -32,10 +32,10 @@ from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class WorkSpaceSerializer(DynamicBaseSerializer):
|
class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||||
owner = UserLiteSerializer(read_only=True)
|
|
||||||
total_members = serializers.IntegerField(read_only=True)
|
total_members = serializers.IntegerField(read_only=True)
|
||||||
total_issues = serializers.IntegerField(read_only=True)
|
total_issues = serializers.IntegerField(read_only=True)
|
||||||
logo_url = serializers.CharField(read_only=True)
|
logo_url = serializers.CharField(read_only=True)
|
||||||
|
role = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
def validate_slug(self, value):
|
def validate_slug(self, value):
|
||||||
# Check if the slug is restricted
|
# Check if the slug is restricted
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ from datetime import date
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
|
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
|
||||||
|
|
||||||
from django.db.models.fields import DateField
|
from django.db.models.fields import DateField
|
||||||
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
|
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
|
||||||
|
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
@ -173,6 +175,11 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
role = (
|
||||||
|
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||||
|
.values("role")
|
||||||
|
)
|
||||||
|
|
||||||
workspace = (
|
workspace = (
|
||||||
Workspace.objects.prefetch_related(
|
Workspace.objects.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
|
|
@ -184,17 +191,19 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||||
)
|
)
|
||||||
.select_related("owner")
|
.select_related("owner")
|
||||||
.annotate(total_members=member_count)
|
.annotate(total_members=member_count)
|
||||||
.annotate(total_issues=issue_count)
|
.annotate(total_issues=issue_count, role=role)
|
||||||
.filter(
|
.filter(
|
||||||
workspace_member__member=request.user, workspace_member__is_active=True
|
workspace_member__member=request.user, workspace_member__is_active=True
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
workspaces = WorkSpaceSerializer(
|
workspaces = WorkSpaceSerializer(
|
||||||
self.filter_queryset(workspace),
|
self.filter_queryset(workspace),
|
||||||
fields=fields if fields else None,
|
fields=fields if fields else None,
|
||||||
many=True,
|
many=True,
|
||||||
).data
|
).data
|
||||||
|
|
||||||
return Response(workspaces, status=status.HTTP_200_OK)
|
return Response(workspaces, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
2
packages/types/src/workspace.d.ts
vendored
2
packages/types/src/workspace.d.ts
vendored
|
|
@ -23,6 +23,8 @@ export interface IWorkspace {
|
||||||
organization_size: string;
|
organization_size: string;
|
||||||
total_issues: number;
|
total_issues: number;
|
||||||
total_projects?: number;
|
total_projects?: number;
|
||||||
|
current_plan?: string;
|
||||||
|
role: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceLite {
|
export interface IWorkspaceLite {
|
||||||
|
|
|
||||||
7
web/ce/components/common/subscription-pill.tsx
Normal file
7
web/ce/components/common/subscription-pill.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { IWorkspace } from "@plane/types";
|
||||||
|
|
||||||
|
type TProps = {
|
||||||
|
workspace: IWorkspace;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SubscriptionPill = (props: TProps) => <></>;
|
||||||
106
web/core/components/workspace/sidebar/dropdown-item.tsx
Normal file
106
web/core/components/workspace/sidebar/dropdown-item.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Check, Settings, UserPlus } from "lucide-react";
|
||||||
|
import { Menu } from "@headlessui/react";
|
||||||
|
import { EUserPermissions } from "@plane/constants";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import { IWorkspace } from "@plane/types";
|
||||||
|
import { cn, getFileURL } from "@plane/utils";
|
||||||
|
import { getUserRole } from "@/helpers/user.helper";
|
||||||
|
import { SubscriptionPill } from "@/plane-web/components/common/subscription-pill";
|
||||||
|
|
||||||
|
type TProps = {
|
||||||
|
workspace: IWorkspace;
|
||||||
|
activeWorkspace: IWorkspace | null;
|
||||||
|
handleItemClick: () => void;
|
||||||
|
handleWorkspaceNavigation: (workspace: IWorkspace) => void;
|
||||||
|
};
|
||||||
|
const SidebarDropdownItem = (props: TProps) => {
|
||||||
|
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation } = props;
|
||||||
|
|
||||||
|
// router params
|
||||||
|
const { workspaceSlug } = useParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={workspace.id}
|
||||||
|
href={`/${workspace.slug}`}
|
||||||
|
onClick={() => {
|
||||||
|
handleWorkspaceNavigation(workspace);
|
||||||
|
handleItemClick();
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
id={workspace.id}
|
||||||
|
>
|
||||||
|
<Menu.Item
|
||||||
|
as="div"
|
||||||
|
className={cn("px-4 py-2", {
|
||||||
|
"bg-custom-sidebar-background-90": workspace.id === activeWorkspace?.id,
|
||||||
|
"hover:bg-custom-sidebar-background-90": workspace.id !== activeWorkspace?.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-1 rounded p-1 text-sm text-custom-sidebar-text-100 ">
|
||||||
|
<div className="flex items-center justify-start gap-2.5 w-[80%] relative">
|
||||||
|
<span
|
||||||
|
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-sm uppercase font-semibold ${
|
||||||
|
!workspace?.logo_url && "rounded-lg bg-custom-primary-500 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||||
|
<img
|
||||||
|
src={getFileURL(workspace.logo_url)}
|
||||||
|
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
|
||||||
|
alt={t("workspace_logo")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(workspace?.name?.[0] ?? "...")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="w-[inherit]">
|
||||||
|
<div
|
||||||
|
className={`truncate text-ellipsis text-sm font-medium ${workspaceSlug === workspace.slug ? "" : "text-custom-text-200"}`}
|
||||||
|
>
|
||||||
|
{workspace.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-custom-text-300 flex gap-2 capitalize w-fit">
|
||||||
|
<span>{getUserRole(workspace.role)?.toLowerCase() || "guest"}</span>
|
||||||
|
<div className="w-1 h-1 bg-custom-text-300/50 rounded-full m-auto" />
|
||||||
|
<span className="capitalize">{t("member", { count: workspace.total_members || 0 })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{workspace.id === activeWorkspace?.id ? (
|
||||||
|
<span className="flex-shrink-0 p-1">
|
||||||
|
<Check className="h-5 w-5 text-custom-sidebar-text-100" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<SubscriptionPill workspace={workspace} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{workspace.id === activeWorkspace?.id && (
|
||||||
|
<div className="mt-2 mb-1 flex gap-2">
|
||||||
|
{workspace?.role > EUserPermissions.GUEST && (
|
||||||
|
<Link
|
||||||
|
href={`/${workspace.slug}/settings`}
|
||||||
|
className="flex border border-custom-border-200 rounded-md py-1 px-2 gap-1 bg-custom-sidebar-background-100"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 text-custom-sidebar-text-100 my-auto" />
|
||||||
|
<span className="text-sm font-medium my-auto">{t("settings")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/${workspace.slug}/settings/members`}
|
||||||
|
className="flex border border-custom-border-200 rounded-md py-1 px-2 gap-1 bg-custom-sidebar-background-100"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4 text-custom-sidebar-text-100 my-auto" />
|
||||||
|
<span className="text-sm font-medium my-auto capitalize">{t("invite")}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarDropdownItem;
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Fragment, Ref, useState, useMemo } from "react";
|
import { Fragment, Ref, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
// icons
|
// icons
|
||||||
import { Check, ChevronDown, LogOut, Mails, PlusSquare, Settings } from "lucide-react";
|
import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
|
||||||
// ui
|
// ui
|
||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
// types
|
// types
|
||||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
import { IWorkspace } from "@plane/types";
|
import { IWorkspace } from "@plane/types";
|
||||||
// plane ui
|
// plane ui
|
||||||
|
|
@ -19,36 +17,16 @@ import { GOD_MODE_URL, cn } from "@/helpers/common.helper";
|
||||||
// helpers
|
// helpers
|
||||||
import { getFileURL } from "@/helpers/file.helper";
|
import { getFileURL } from "@/helpers/file.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useAppTheme, useUser, useUserPermissions, useUserProfile, useWorkspace } from "@/hooks/store";
|
import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
|
||||||
// plane web constants
|
|
||||||
// plane web helpers
|
// plane web helpers
|
||||||
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
|
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
|
||||||
// components
|
// components
|
||||||
import { WorkspaceLogo } from "../logo";
|
import { WorkspaceLogo } from "../logo";
|
||||||
|
import SidebarDropdownItem from "./dropdown-item";
|
||||||
|
|
||||||
export const SidebarDropdown = observer(() => {
|
export const SidebarDropdown = observer(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const userLinks = useMemo(
|
|
||||||
() => (workspaceSlug: string) => [
|
|
||||||
{
|
|
||||||
key: "workspace_invites",
|
|
||||||
name: t("workspace_invites"),
|
|
||||||
href: "/invitations",
|
|
||||||
icon: Mails,
|
|
||||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "settings",
|
|
||||||
name: t("workspace_settings.label"),
|
|
||||||
href: `/${workspaceSlug}/settings`,
|
|
||||||
icon: Settings,
|
|
||||||
access: [EUserPermissions.ADMIN],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
// router params
|
|
||||||
const { workspaceSlug } = useParams();
|
|
||||||
// store hooks
|
// store hooks
|
||||||
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
|
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
|
||||||
const { data: currentUser } = useUser();
|
const { data: currentUser } = useUser();
|
||||||
|
|
@ -58,8 +36,6 @@ export const SidebarDropdown = observer(() => {
|
||||||
signOut,
|
signOut,
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const { updateUserProfile } = useUserProfile();
|
const { updateUserProfile } = useUserProfile();
|
||||||
const { allowPermissions } = useUserPermissions();
|
|
||||||
// derived values
|
|
||||||
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
|
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
|
||||||
|
|
||||||
const isUserInstanceAdmin = false;
|
const isUserInstanceAdmin = false;
|
||||||
|
|
@ -150,57 +126,26 @@ export const SidebarDropdown = observer(() => {
|
||||||
>
|
>
|
||||||
<Menu.Items as={Fragment}>
|
<Menu.Items as={Fragment}>
|
||||||
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-full max-w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
|
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-full max-w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
|
||||||
<div className="vertical-scrollbar scrollbar-sm mb-2 flex max-h-96 flex-col items-start justify-start gap-2 overflow-y-scroll px-4">
|
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
|
||||||
<h6 className="sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-sidebar-text-400">
|
<span className="rounded-md px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
|
||||||
{currentUser?.email}
|
{currentUser?.email}
|
||||||
</h6>
|
</span>
|
||||||
{workspacesList ? (
|
{workspacesList ? (
|
||||||
<div className="size-full flex flex-col items-start justify-start gap-1.5">
|
<div className="size-full flex flex-col items-start justify-start">
|
||||||
{workspacesList.map((workspace) => (
|
{(activeWorkspace
|
||||||
<Link
|
? [
|
||||||
|
activeWorkspace,
|
||||||
|
...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
|
||||||
|
]
|
||||||
|
: workspacesList
|
||||||
|
).map((workspace) => (
|
||||||
|
<SidebarDropdownItem
|
||||||
key={workspace.id}
|
key={workspace.id}
|
||||||
href={`/${workspace.slug}`}
|
workspace={workspace}
|
||||||
onClick={() => {
|
activeWorkspace={activeWorkspace}
|
||||||
handleWorkspaceNavigation(workspace);
|
handleItemClick={handleItemClick}
|
||||||
handleItemClick();
|
handleWorkspaceNavigation={handleWorkspaceNavigation}
|
||||||
}}
|
/>
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Menu.Item
|
|
||||||
as="div"
|
|
||||||
className="flex items-center justify-between gap-1 rounded p-1 text-sm text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-80"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-2.5 truncate">
|
|
||||||
<span
|
|
||||||
className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-xs uppercase ${
|
|
||||||
!workspace?.logo_url && "rounded bg-custom-primary-500 text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
|
||||||
<img
|
|
||||||
src={getFileURL(workspace.logo_url)}
|
|
||||||
className="absolute left-0 top-0 h-full w-full rounded object-cover"
|
|
||||||
alt={t("workspace_logo")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
(workspace?.name?.[0] ?? "...")
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<h5
|
|
||||||
className={`truncate text-sm font-medium ${
|
|
||||||
workspaceSlug === workspace.slug ? "" : "text-custom-text-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{workspace.name}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
{workspace.id === activeWorkspace?.id && (
|
|
||||||
<span className="flex-shrink-0 p-1">
|
|
||||||
<Check className="h-5 w-5 text-custom-sidebar-text-100" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -219,43 +164,33 @@ export const SidebarDropdown = observer(() => {
|
||||||
as="div"
|
as="div"
|
||||||
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||||
>
|
>
|
||||||
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
|
<CirclePlus className="size-4 flex-shrink-0" />
|
||||||
{t("create_workspace")}
|
{t("create_workspace")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{userLinks(workspaceSlug?.toString() ?? "").map(
|
|
||||||
(link, index) =>
|
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
|
||||||
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE) && (
|
<Menu.Item
|
||||||
<Link
|
as="div"
|
||||||
key={link.key}
|
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||||
href={link.href}
|
>
|
||||||
className="w-full"
|
<Mails className="h-4 w-4 flex-shrink-0" />
|
||||||
onClick={() => {
|
{t("workspace_invites")}
|
||||||
if (index > 0) handleItemClick();
|
</Menu.Item>
|
||||||
}}
|
</Link>
|
||||||
>
|
|
||||||
<Menu.Item
|
<div className="w-full">
|
||||||
as="div"
|
<Menu.Item
|
||||||
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
as="button"
|
||||||
>
|
type="button"
|
||||||
<link.icon className="h-4 w-4 flex-shrink-0" />
|
className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm font-medium text-red-600 hover:bg-custom-sidebar-background-80"
|
||||||
{link.name}
|
onClick={handleSignOut}
|
||||||
</Menu.Item>
|
>
|
||||||
</Link>
|
<LogOut className="size-4 flex-shrink-0" />
|
||||||
)
|
{t("sign_out")}
|
||||||
)}
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full px-4 py-2">
|
|
||||||
<Menu.Item
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm font-medium text-red-600 hover:bg-custom-sidebar-background-80"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
>
|
|
||||||
<LogOut className="size-4 flex-shrink-0" />
|
|
||||||
{t("sign_out")}
|
|
||||||
</Menu.Item>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
|
|
|
||||||
1
web/ee/components/common/subscription-pill.tsx
Normal file
1
web/ee/components/common/subscription-pill.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/components/common/subscription-pill";
|
||||||
Loading…
Add table
Add a link
Reference in a new issue