feat: basic mobile support (#64)

* feat: add mobile navigation with responsive layout and theme controls

* feat: prevent workspace owners from removing themselves and update workspace dropdown styling

* feat: add useClickOutside hook and event listener utilities for improved UI interactions

* chore: improve mobile UI styling

* refactor: improve UI layout and buttons in boards/members views
feat: adjust /members layout

* fix: standardize layout containers and improve mobile navigation styling

* feat: update card view layout

* feat: add responsive layout to board view

* feat: readjust boards page layout

* feat: enhance table view on mobile for members page

* feat: extend mobile support with sliding side panels

* fix: prevent two panels from being open at the same time

* feat: close panels when clicking outside

* fix: adjust font-sizing for import source

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
LovelessCodes
2025-07-02 12:58:10 +02:00
committed by GitHub
parent 316f148663
commit ec1b5d5acb
17 changed files with 479 additions and 127 deletions

View File

@@ -1,11 +1,69 @@
import Link from "next/link";
import { useRef, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
TbLayoutSidebarRightCollapse,
TbLayoutSidebarRightExpand,
} from "react-icons/tb";
import { api } from "~/utils/api";
import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import FeedbackButton from "./FeedbackButton";
import SideNavigation from "./SideNavigation";
export default function Dashboard(props: { children: React.ReactNode }) {
const { data, isLoading } = api.user.getUser.useQuery();
interface DashboardProps {
children: React.ReactNode;
rightPanel?: React.ReactNode;
hasRightPanel?: boolean;
}
export default function Dashboard({
children,
rightPanel,
hasRightPanel = false,
}: DashboardProps) {
const { data: session, isPending: sessionLoading } = authClient.useSession();
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const sideNavRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const sideNavButtonRef = useRef<HTMLButtonElement>(null);
const rightPanelButtonRef = useRef<HTMLButtonElement>(null);
const toggleSideNav = () => {
setIsSideNavOpen(!isSideNavOpen);
if (!isSideNavOpen) {
setIsRightPanelOpen(false);
}
};
const toggleRightPanel = () => {
setIsRightPanelOpen(!isRightPanelOpen);
if (!isRightPanelOpen) {
setIsSideNavOpen(false);
}
};
useClickOutside(sideNavRef, (event) => {
if (sideNavButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isSideNavOpen) {
setIsSideNavOpen(false);
}
});
useClickOutside(rightPanelRef, (event) => {
if (rightPanelButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isRightPanelOpen) {
setIsRightPanelOpen(false);
}
});
return (
<>
@@ -15,24 +73,91 @@ export default function Dashboard(props: { children: React.ReactNode }) {
overflow: hidden;
}
`}</style>
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
<div className="h-screen flex-col items-center bg-light-100 dark:bg-dark-50 md:flex md:min-w-[800px]">
<div className="m-auto flex h-12 min-h-12 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400 md:h-16 md:min-h-16">
<div className="my-auto flex w-full items-center justify-between">
<Link href="/">
<button
ref={sideNavButtonRef}
onClick={toggleSideNav}
className="text-neutral-900 dark:text-dark-1000 md:hidden"
>
{isSideNavOpen ? (
<TbLayoutSidebarLeftCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarLeftExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
<Link href="/" className="hidden md:block">
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<FeedbackButton />
<div className="flex items-center gap-2">
{hasRightPanel && (
<button
ref={rightPanelButtonRef}
onClick={toggleRightPanel}
className="text-neutral-900 dark:text-dark-1000 md:hidden"
>
{isRightPanelOpen ? (
<TbLayoutSidebarRightCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarRightExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
)}
<div className="hidden md:block">
<FeedbackButton />
</div>
</div>
</div>
</div>
<div className="flex h-full w-full">
<SideNavigation
user={{ email: data?.email, image: data?.image }}
isLoading={isLoading}
/>
<div className="w-full overflow-hidden">{props.children}</div>
<div
ref={sideNavRef}
className={`fixed left-0 z-50 h-[calc(100vh-3rem)] transform transition-transform duration-300 ease-in-out md:relative md:h-[calc(100vh-4rem)] md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
>
<SideNavigation
user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading}
/>
</div>
<div className="relative flex w-full overflow-hidden">
<div className="w-full overflow-hidden">{children}</div>
{/* Mobile Right Panel */}
{hasRightPanel && rightPanel && (
<div
ref={rightPanelRef}
className={`fixed right-0 top-12 z-50 h-[calc(100vh-3rem)] w-80 transform bg-light-200 transition-transform duration-300 ease-in-out dark:bg-dark-100 md:hidden ${
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="h-full overflow-y-auto">{rightPanel}</div>
</div>
)}
{/* Desktop Right Panel */}
{hasRightPanel && rightPanel && (
<div className="hidden md:block">{rightPanel}</div>
)}
</div>
</div>
</div>
</>

View File

@@ -55,7 +55,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<div className="flex w-full flex-col gap-1">
<div className="relative flex">
{prefix && (
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-sm dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
{prefix}
</div>
)}

View File

@@ -19,10 +19,12 @@ export function NewWorkspaceForm() {
const { showPopup } = usePopup();
const { switchWorkspace } = useWorkspace();
const { register, handleSubmit } = useForm<FormValues>();
const utils = api.useUtils();
const createWorkspace = api.workspace.create.useMutation({
onSuccess: (values) => {
if (values.publicId && values.name) {
utils.workspace.all.invalidate();
switchWorkspace({
publicId: values.publicId,
name: values.name,

View File

@@ -54,7 +54,7 @@ export default function SideNavigation({
return (
<>
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
<nav className="flex h-full w-64 flex-col justify-between border-r border-light-600 bg-light-50 px-3 pb-3 pt-5 dark:border-dark-400 dark:bg-dark-50">
<div>
<WorkspaceMenu />
<ul role="list" className="space-y-1">

View File

@@ -0,0 +1,59 @@
import type { RefObject } from "react";
import { useEventListener } from "./useEventListener";
/** Supported event types. */
type EventType =
| "mousedown"
| "mouseup"
| "touchstart"
| "touchend"
| "focusin"
| "focusout";
/**
* Custom hook that handles clicks outside a specified element.
* @template T - The type of the element's reference.
* @param {RefObject<T> | RefObject<T>[]} ref - The React ref object(s) representing the element(s) to watch for outside clicks.
* @param {(event: MouseEvent | TouchEvent | FocusEvent) => void} handler - The callback function to be executed when a click outside the element occurs.
* @param {EventType} [eventType] - The mouse event type to listen for (optional, default is 'mousedown').
* @param {?AddEventListenerOptions} [eventListenerOptions] - The options object to be passed to the `addEventListener` method (optional).
* @returns {void}
* @example
* ```tsx
* const containerRef = useRef(null);
* useClickOutside([containerRef], () => {
* // Handle clicks outside the container.
* });
* ```
*/
export function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T> | RefObject<T>[],
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
eventType: EventType = "mousedown",
eventListenerOptions: AddEventListenerOptions = {},
): void {
useEventListener(
eventType,
(event) => {
const target = event.target as Node;
// Do nothing if the target is not connected element with document
if (!target.isConnected) {
return;
}
const isOutside = Array.isArray(ref)
? ref
.filter((r) => Boolean(r.current))
.every((r) => r.current && !r.current.contains(target))
: ref.current && !ref.current.contains(target);
if (isOutside) {
handler(event);
}
},
undefined,
eventListenerOptions,
);
}

View File

@@ -0,0 +1,120 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
// MediaQueryList Event based useEventListener interface
function useEventListener<K extends keyof MediaQueryListEventMap>(
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
element: RefObject<MediaQueryList>,
options?: boolean | AddEventListenerOptions,
): void;
// Window Event based useEventListener interface
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions,
): void;
// Element Event based useEventListener interface
function useEventListener<
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
T extends Element = K extends keyof HTMLElementEventMap
? HTMLDivElement
: SVGElement,
>(
eventName: K,
handler:
| ((event: HTMLElementEventMap[K]) => void)
| ((event: SVGElementEventMap[K]) => void),
element: RefObject<T>,
options?: boolean | AddEventListenerOptions,
): void;
// Document Event based useEventListener interface
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: RefObject<Document>,
options?: boolean | AddEventListenerOptions,
): void;
/**
* Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
* @template KW - The type of event for window events.
* @template KH - The type of event for HTML or SVG element events.
* @template KM - The type of event for media query list events.
* @template T - The type of the DOM element (default is `HTMLElement`).
* @param {KW | KH | KM} eventName - The name of the event to listen for.
* @param {(event: WindowEventMap[KW] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | MediaQueryListEventMap[KM] | Event) => void} handler - The event handler function.
* @param {RefObject<T>} [element] - The DOM element or media query list to attach the event listener to (optional).
* @param {boolean | AddEventListenerOptions} [options] - An options object that specifies characteristics about the event listener (optional).
* @public
* @see [Documentation](https://usehooks-ts.com/react-hook/use-event-listener)
* @example
* ```tsx
* // Example 1: Attach a window event listener
* useEventListener('resize', handleResize);
* ```
* @example
* ```tsx
* // Example 2: Attach a document event listener with options
* const elementRef = useRef(document);
* useEventListener('click', handleClick, elementRef, { capture: true });
* ```
* @example
* ```tsx
* // Example 3: Attach an element event listener
* const buttonRef = useRef<HTMLButtonElement>(null);
* useEventListener('click', handleButtonClick, buttonRef);
* ```
*/
function useEventListener<
KW extends keyof WindowEventMap,
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
KM extends keyof MediaQueryListEventMap,
T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
>(
eventName: KW | KH | KM,
handler: (
event:
| WindowEventMap[KW]
| HTMLElementEventMap[KH]
| SVGElementEventMap[KH]
| MediaQueryListEventMap[KM]
| Event,
) => void,
element?: RefObject<T>,
options?: boolean | AddEventListenerOptions,
) {
// Create a ref that stores handler
const savedHandler = useRef(handler);
useIsomorphicLayoutEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
// Define the listening target
const targetElement: T | Window = element?.current ?? window;
if (!(targetElement && targetElement.addEventListener)) return;
// Create event listener that calls handler function stored in ref
const listener: typeof handler = (event) => {
savedHandler.current(event);
};
targetElement.addEventListener(eventName, listener, options);
// Remove event listener on cleanup
return () => {
targetElement.removeEventListener(eventName, listener, options);
};
}, [eventName, element, options]);
}
export { useEventListener };

View File

@@ -0,0 +1,15 @@
import { useEffect, useLayoutEffect } from "react";
/**
* Custom hook that uses either `useLayoutEffect` or `useEffect` based on the environment (client-side or server-side).
* @param {Function} effect - The effect function to be executed.
* @param {Array<any>} [dependencies] - An array of dependencies for the effect (optional).
* @example
* ```tsx
* useIsomorphicLayoutEffect(() => {
* // Code to be executed during the layout phase on the client side
* }, [dependency1, dependency2]);
* ```
*/
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;

View File

@@ -2,6 +2,7 @@ import "~/styles/globals.css";
import "~/utils/i18n";
import type { AppType } from "next/app";
import { Viewport } from "next";
import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script";
import { env } from "next-runtime-env";
@@ -26,6 +27,13 @@ export const metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
const MyApp: AppType = ({ Component, pageProps }) => {
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");

View File

@@ -1,12 +1,12 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import CardView from "~/views/card";
import { WorkspaceProvider } from "~/providers/workspace";
import CardView, { CardRightPanel } from "~/views/card";
export default function CardPage() {
return (
<WorkspaceProvider>
<Dashboard>
<Dashboard hasRightPanel rightPanel={<CardRightPanel />}>
<CardView />
</Dashboard>
<Popup />

View File

@@ -2,10 +2,11 @@
@tailwind components;
@tailwind utilities;
[contenteditable=true]:empty:before {
[contenteditable="true"]:empty:before {
content: attr(placeholder);
display: block;
color: #707070;
font-size: 0.8rem;
}
.gradient-border::before {
@@ -18,7 +19,7 @@
transparent 110deg,
transparent 360deg
);
content: '';
content: "";
position: absolute;
inset: -50%;
width: 200%;

View File

@@ -240,7 +240,7 @@ export default function BoardPage() {
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
<div className="z-10 flex w-full justify-between p-8">
<div className="z-10 flex w-full flex-col justify-between p-6 md:flex-row md:p-8">
{isLoading && !boardData && (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
@@ -249,7 +249,7 @@ export default function BoardPage() {
{boardData && (
<form
onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none"
className="order-2 focus-visible:outline-none md:order-1"
>
<input
id="name"
@@ -261,12 +261,12 @@ export default function BoardPage() {
</form>
)}
{!boardData && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
{t`Board not found`}
</p>
)}
<div className="flex items-center space-x-2">
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""}

View File

@@ -81,7 +81,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
{integrationProviders[field.value]?.icon}
<span className="ml-2 block truncate">
<span className="ml-2 block truncate text-sm">
{integrationProviders[field.value]?.name}
</span>
</span>
@@ -113,7 +113,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
integrationProviders[integration.provider]
?.icon
}
<span className="ml-2 block truncate font-normal">
<span className="ml-2 block truncate text-sm font-normal">
{
integrationProviders[integration.provider]
?.name
@@ -125,12 +125,12 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
) : (
<Listbox.Option
key="trello_placeholder"
className="relative cursor-default select-none px-1"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate font-normal">
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import Button from "~/components/Button";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -19,38 +20,32 @@ export default function BoardsPage() {
return (
<>
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
<div className="h-full p-8">
<div className="mb-8 flex w-full justify-between">
<div className="m-auto h-full max-w-[1600px] px-5 py-6 md:px-8 md:py-8">
<div className="mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Boards`}
</h1>
<div className="flex">
<button
<div className="flex gap-2">
<Button
type="button"
className="bg-dark-3000 mr-2 flex items-center gap-x-1.5 rounded-md border-[1px] border-light-600 px-3 py-2 text-sm text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-dark-600 dark:text-dark-1000"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
<div className="flex h-5 w-5 items-center">
<HiArrowDownTray
className="-mr-0.5 h-4 w-4"
aria-hidden="true"
/>
</div>
{t`Import`}
</button>
<button
</Button>
<Button
type="button"
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}
>
<div className="h-5 w-5 items-center">
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
</div>
{t`New`}
</button>
</Button>
</div>
</div>

View File

@@ -145,7 +145,7 @@ const Comment = ({
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end space-x-2">
<Button

View File

@@ -32,29 +32,18 @@ interface FormValues {
description: string;
}
export default function CardPage() {
export function CardRightPanel() {
const router = useRouter();
const utils = api.useUtils();
const { modalContentType, entityId } = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery({
const { data: card } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const refetchCard = async () => {
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
};
const board = card?.list.board;
const boardId = board?.publicId;
const labels = board?.labels;
const activities = card?.activities;
const workspaceMembers = board?.workspace.members;
const selectedLabels = card?.labels;
const selectedMembers = card?.members;
@@ -109,6 +98,59 @@ export default function CardPage() {
};
}) ?? [];
return (
<div className="h-full w-[360px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
<ListSelector
cardPublicId={cardId ?? ""}
lists={formattedLists}
isLoading={!card}
/>
</div>
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Labels`}</p>
<LabelSelector
cardPublicId={cardId ?? ""}
labels={formattedLabels}
isLoading={!card}
/>
</div>
<div className="flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId ?? ""}
members={formattedMembers}
isLoading={!card}
/>
</div>
</div>
);
}
export default function CardPage() {
const router = useRouter();
const utils = api.useUtils();
const { modalContentType, entityId } = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const refetchCard = async () => {
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
};
const board = card?.list.board;
const boardId = board?.publicId;
const activities = card?.activities;
const updateCard = api.card.update.useMutation({
onError: () => {
showPopup({
@@ -138,8 +180,6 @@ export default function CardPage() {
});
};
const description = watch("description");
if (!cardId) return <></>;
return (
@@ -149,7 +189,7 @@ export default function CardPage() {
/>
<div className="flex h-full flex-1 flex-row">
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-6 md:p-8">
<div className="mb-8 flex w-full items-center justify-between">
{!card && isLoading && (
<div className="flex space-x-2">
@@ -196,7 +236,7 @@ export default function CardPage() {
</div>
{card && (
<>
<div className="mb-10 flex w-full max-w-2xl justify-between">
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
@@ -230,32 +270,6 @@ export default function CardPage() {
)}
</div>
</div>
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">{t`List`}</p>
<ListSelector
cardPublicId={cardId}
lists={formattedLists}
isLoading={!card}
/>
</div>
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">{t`Labels`}</p>
<LabelSelector
cardPublicId={cardId}
labels={formattedLabels}
isLoading={!card}
/>
</div>
<div className="flex w-full">
<p className="my-2 w-[100px] text-sm">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId}
members={formattedMembers}
isLoading={!card}
/>
</div>
</div>
<Modal>
{modalContentType === "NEW_LABEL" && (

View File

@@ -2,6 +2,8 @@ import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown";
@@ -26,6 +28,7 @@ export default function MembersPage() {
const TableRow = ({
memberPublicId,
memberId,
memberName,
memberEmail,
memberImage,
@@ -35,6 +38,7 @@ export default function MembersPage() {
showSkeleton,
}: {
memberPublicId?: string;
memberId?: string | null | undefined;
memberName?: string | null | undefined;
memberEmail?: string | null | undefined;
memberImage?: string | null | undefined;
@@ -43,13 +47,19 @@ export default function MembersPage() {
isLastRow?: boolean;
showSkeleton?: boolean;
}) => {
const { data: session } = authClient.useSession();
return (
<tr className="rounded-b-lg">
<td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}>
<div className="flex items-center p-4">
<td
className={twMerge(
"w-full sm:w-[65%]",
isLastRow ? "rounded-bl-lg" : "",
)}
>
<div className="flex items-center p-2 sm:p-4">
<div className="flex-shrink-0">
{showSkeleton ? (
<div className="h-9 w-9 animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
<div className="h-8 w-8 animate-pulse rounded-full bg-light-200 dark:bg-dark-200 sm:h-9 sm:w-9" />
) : (
<Avatar
name={memberName ?? ""}
@@ -63,7 +73,7 @@ export default function MembersPage() {
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000",
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
showSkeleton &&
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
@@ -73,7 +83,7 @@ export default function MembersPage() {
</div>
<p
className={twMerge(
"truncate text-sm text-dark-900",
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
@@ -86,15 +96,15 @@ export default function MembersPage() {
</td>
<td
className={twMerge(
"w-[35%] min-w-[150px]",
"w-auto min-w-[120px] sm:w-[35%] sm:min-w-[150px]",
isLastRow && "rounded-br-lg",
)}
>
<div className="flex w-full items-center justify-between px-3">
<div>
<div className="flex w-full items-center justify-between px-2 sm:px-3">
<div className="flex flex-col sm:flex-row sm:items-center">
<span
className={twMerge(
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20",
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
showSkeleton &&
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
)}
@@ -103,7 +113,7 @@ export default function MembersPage() {
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
</span>
{memberStatus === "invited" && (
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
{t`Pending`}
</span>
)}
@@ -114,24 +124,26 @@ export default function MembersPage() {
(workspace.role !== "admin" || showSkeleton) && "hidden",
)}
>
<Dropdown
items={[
{
label: t`Remove member`,
action: () =>
openModal(
"REMOVE_MEMBER",
memberPublicId,
memberEmail ?? "",
),
},
]}
>
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Dropdown>
{session?.user.id !== memberId && (
<Dropdown
items={[
{
label: t`Remove member`,
action: () =>
openModal(
"REMOVE_MEMBER",
memberPublicId,
memberEmail ?? "",
),
},
]}
>
<HiEllipsisHorizontal
size={20}
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
/>
</Dropdown>
)}
</div>
</div>
</td>
@@ -142,7 +154,7 @@ export default function MembersPage() {
return (
<>
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto max-w-[1600px] px-28 py-12">
<div className="m-auto h-full max-w-[1600px] p-6 px-5 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Members`}
@@ -159,21 +171,21 @@ export default function MembersPage() {
</div>
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-visible sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full px-4 py-2 align-middle sm:px-6 lg:px-8">
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600">
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
<tr>
<th
scope="col"
className="w-[65%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
className="w-full rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[65%] sm:pl-6"
>
{t`User`}
</th>
<th
scope="col"
className="w-[35%] rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
className="w-auto whitespace-nowrap rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[35%]"
>
{t`Role`}
</th>
@@ -185,6 +197,7 @@ export default function MembersPage() {
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}

View File

@@ -106,7 +106,7 @@ export default function SettingsPage() {
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto">
<PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} />
<div className="px-28 py-12">
<div className="m-auto max-w-[1600px] px-5 py-6 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Settings`}