Skip to content
Open
8 changes: 8 additions & 0 deletions apps/web/src/components/ColoredBackground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const ColoredBackground = ({ color }: { color: string }) => (
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full transition-opacity group-hover:opacity-90"
style={{ backgroundColor: color }}
/>
);

export default ColoredBackground;
2 changes: 1 addition & 1 deletion apps/web/src/components/PatternedBackground.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const PatternedBackground = () => (
<div className="absolute inset-0 h-full w-full">
<div className="pointer-events-none absolute inset-0 z-0 h-full w-full transition-opacity group-hover:opacity-90">
<svg className="h-full w-full">
<pattern
id="pattern"
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,15 @@ export const getAvatarUrl = (imageOrKey: string | null) => {

return "";
};

export const isLightHexColor = (color: string | null | undefined) => {
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) return null;

const red = parseInt(color.slice(1, 3), 16);
const green = parseInt(color.slice(3, 5), 16);
const blue = parseInt(color.slice(5, 7), 16);

const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;

return luminance > 0.6;
};
6 changes: 6 additions & 0 deletions apps/web/src/views/board/components/BoardDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HiArrowRightOnRectangle,
HiEllipsisHorizontal,
HiLink,
HiOutlineCog6Tooth,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
HiOutlineStar,
Expand Down Expand Up @@ -102,6 +103,11 @@ export default function BoardDropdown({
: []),
...(!isTemplate && canEditBoard
? [
{
label: t`Board settings`,
action: () => openModal("BOARD_SETTINGS"),
icon: <HiOutlineCog6Tooth className="h-[16px] w-[16px] text-dark-900" />,
},
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
Expand Down
156 changes: 156 additions & 0 deletions apps/web/src/views/board/components/BoardSettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiCheck, HiXMark } from "react-icons/hi2";
import { z } from "zod";

import { colours } from "@kan/shared/constants";

import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";

const INITIAL_BOARD_BACKGROUND_COLOR =
colours.find((colour) => colour.name === "Teal")?.code ?? "#000000";

interface QueryParams {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
}

export function BoardSettingsForm({
boardPublicId,
backgroundColor,
queryParams,
}: {
boardPublicId: string;
backgroundColor: string | null;
queryParams: QueryParams;
}) {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();

const schema = z.object({
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
});

type FormValues = z.infer<typeof schema>;

const {
register,
handleSubmit,
formState: { isDirty, errors },
watch,
setValue,
} = useForm<FormValues>({
resolver: zodResolver(schema),
values: {
backgroundColor: backgroundColor ?? "",
},
mode: "onChange",
});

const selectedColour = backgroundColor;

const { data: board, isLoading } = api.board.byId.useQuery(
{ boardPublicId: boardPublicId ?? "" },
{ enabled: !!boardPublicId && boardPublicId.length >= 12 },
);

const updateBoard = api.board.update.useMutation({
onError: () => {
showPopup({
header: t`Unable to update board settings`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.board.byId.invalidate(queryParams);
await utils.board.bySlug.invalidate();
},
});

const setColour = (colourCode: string | null) => {
updateBoard.mutate({
boardPublicId,
backgroundColor: colourCode,
});
};

const resetBackground = () => {
updateBoard.mutate({
boardPublicId,
backgroundColor: null,
});
};

useEffect(() => {
const backgroundColorElement: HTMLElement | null =
document.querySelector<HTMLElement>("#board-background-color");
if (backgroundColorElement) backgroundColorElement.focus();
}, []);

return (
<form>
<div className="p-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
{t`Board settings`}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>

<div className="mb-3 text-sm text-light-900 dark:text-dark-900">
{t`Background color`}
</div>
<div className="flex flex-wrap gap-2">
{/* None / clear */}
<button
type="button"
aria-label={t`No cover colour`}
onClick={() => setColour(null)}
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-400 bg-light-50 text-light-900 hover:border-light-600 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-900 dark:hover:border-dark-600"
>
{selectedColour === null ? (
<HiCheck className="h-4 w-4" />
) : (
<HiXMark className="h-4 w-4" />
)}
</button>
{colours.map((colour) => {
const isSelected = selectedColour === colour.code;
return (
<button
key={colour.code}
type="button"
aria-label={colour.name}
title={colour.name}
onClick={() => setColour(colour.code)}
style={{ backgroundColor: colour.code }}
className="flex h-8 w-8 items-center justify-center rounded-full border border-black/10 hover:ring-2 hover:ring-light-600 dark:border-white/10 dark:hover:ring-dark-600"
>
{isSelected && <HiCheck className="h-4 w-4 text-white" />}
</button>
);
})}
</div>
</div>
</form>
);
}
15 changes: 14 additions & 1 deletion apps/web/src/views/board/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { UpdateBoardInput } from "@kan/api/types";

import type { CardContextMenuAction } from "./components/CardContextMenu";
import Button from "~/components/Button";
import ColoredBackground from "~/components/ColoredBackground";
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
import { LabelForm } from "~/components/LabelForm";
import Modal from "~/components/modal";
Expand All @@ -38,6 +39,7 @@ import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers";
import { DeleteCardConfirmation } from "~/views/card/components/DeleteCardConfirmation";
import BoardDropdown from "./components/BoardDropdown";
import { BoardSettingsForm } from "./components/BoardSettingsForm";
import Card from "./components/Card";
import { CardContextDueDateModal } from "./components/CardContextDueDateModal";
import { CardContextDuplicateModal } from "./components/CardContextDuplicateModal";
Expand Down Expand Up @@ -463,6 +465,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {

<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "BOARD_SETTINGS"}
>
<BoardSettingsForm
boardPublicId={boardId ?? ""}
backgroundColor={boardData ? boardData.backgroundColor : null}
queryParams={queryParams}
/>
isVisible={isOpen && modalContentType === "MOVE_BOARD"}
>
<MoveBoardForm boardPublicId={boardId ?? ""} />
Expand Down Expand Up @@ -538,7 +547,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
title={`${boardData?.name ?? (isTemplate ? t`Board` : t`Template`)} | ${workspace.name ?? t`Workspace`}`}
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
{boardData?.backgroundColor ? (
<ColoredBackground color={boardData.backgroundColor} />
) : (
<PatternedBackground />
)}
<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">
Expand Down
59 changes: 42 additions & 17 deletions apps/web/src/views/boards/components/BoardsList.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
import { motion } from "framer-motion";
import {
HiOutlineRectangleStack,
HiOutlineStar,
HiStar,
} from "react-icons/hi2";

import Button from "~/components/Button";
import ColoredBackground from "~/components/ColoredBackground";
import PatternedBackground from "~/components/PatternedBackground";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";

export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
export function BoardsList({
isTemplate,
archived = false,
}: {
isTemplate?: boolean;
archived?: boolean;
}) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { canCreateBoard } = usePermissions();
Expand All @@ -34,7 +46,7 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
const handleToggleFavorite = (
e: React.MouseEvent,
boardPublicId: string,
currentFavorite: boolean | undefined
currentFavorite: boolean | undefined,
) => {
e.preventDefault();
e.stopPropagation();
Expand All @@ -44,7 +56,6 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
});
};


if (isLoading)
return (
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
Expand All @@ -60,16 +71,18 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
<div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
{archived
? t`No archived boards`
: t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
{archived
? t`Boards you archive will appear here.`
: t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Tooltip
content={
!canCreateBoard ? t`You don't have permission` : undefined
}
content={!canCreateBoard ? t`You don't have permission` : undefined}
>
<Button
onClick={() => {
Expand Down Expand Up @@ -99,30 +112,42 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
type: "spring",
stiffness: 300,
damping: 30,
mass: 1
mass: 1,
},
opacity: { duration: 0.2 },
scale: { duration: 0.2 }
scale: { duration: 0.2 },
}}
>
<Link
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
>
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
{board.backgroundColor ? (
<ColoredBackground color={board.backgroundColor} />
) : (
<PatternedBackground />
)}
<div className="pointer-events-none absolute inset-0 z-[1] rounded-md bg-black/0 transition-colors group-hover:bg-black/10" />
<button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) =>
handleToggleFavorite(e, board.publicId, board.favorite)
}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${
board.favorite
? ""
: "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={
board.favorite ? "Remove from favorites" : "Add to favorites"
}
>
{board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)}
</button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
<p className="relative z-10 px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>
</div>
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/views/public/board/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { HiLink, HiOutlineLockClosed } from "react-icons/hi2";

import Button from "~/components/Button";
import ColoredBackground from "~/components/ColoredBackground";
import Modal from "~/components/modal";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
Expand Down Expand Up @@ -133,7 +134,11 @@ export default function PublicBoardView() {

<div className="relative flex h-screen flex-col bg-light-100 px-4 pt-4 dark:bg-dark-50">
<div className="relative h-full overflow-hidden rounded-md border pb-8 dark:border-dark-200">
<PatternedBackground />
{data?.backgroundColor ? (
<ColoredBackground color={data.backgroundColor} />
) : (
<PatternedBackground />
)}
<div className="z-10 flex w-full justify-between p-8">
{isLoading || !router.isReady ? (
<div className="flex space-x-2">
Expand Down
Loading
Loading