diff --git a/apps/web/src/components/CheckboxDropdown.tsx b/apps/web/src/components/CheckboxDropdown.tsx index 1011f612..41516232 100644 --- a/apps/web/src/components/CheckboxDropdown.tsx +++ b/apps/web/src/components/CheckboxDropdown.tsx @@ -21,7 +21,9 @@ interface CheckboxDropdownProps { items?: Item[]; groups?: Group[]; menuSpacing?: "sm" | "md" | "lg"; + position?: "left" | "right"; handleSelect: (groupKey: string | null, item: { key: string }) => void; + asChild?: boolean; } export default function CheckboxDropdown({ @@ -29,7 +31,9 @@ export default function CheckboxDropdown({ items, groups, menuSpacing = "sm", + position = "left", handleSelect, + asChild = true, }: CheckboxDropdownProps) { const [selectedGroup, setSelectedGroup] = useState(null); @@ -45,7 +49,10 @@ export default function CheckboxDropdown({ className="relative flex w-full flex-wrap items-center text-left" > <> - + {children} @@ -61,7 +68,8 @@ export default function CheckboxDropdown({ > diff --git a/apps/web/src/components/NewWorkspaceForm.tsx b/apps/web/src/components/NewWorkspaceForm.tsx index 609906be..d40bbe0a 100644 --- a/apps/web/src/components/NewWorkspaceForm.tsx +++ b/apps/web/src/components/NewWorkspaceForm.tsx @@ -21,8 +21,14 @@ export function NewWorkspaceForm() { const createWorkspace = api.workspace.create.useMutation({ onSuccess: (values) => { - if (values?.publicId && values.name) { - switchWorkspace({ publicId: values.publicId, name: values.name }); + if (values.publicId && values.name) { + switchWorkspace({ + publicId: values.publicId, + name: values.name, + // description: values.description, + // slug: values.slug, + // plan: values.plan, + }); closeModal(); } }, @@ -30,13 +36,14 @@ export function NewWorkspaceForm() { showPopup({ header: "Unable to create workspace", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); useEffect(() => { const nameElement: HTMLElement | null = - document?.querySelector("#workspace-name"); + document.querySelector("#workspace-name"); if (nameElement) nameElement.focus(); }, []); diff --git a/apps/web/src/components/Popup.tsx b/apps/web/src/components/Popup.tsx index 4e93ae61..72c8431b 100644 --- a/apps/web/src/components/Popup.tsx +++ b/apps/web/src/components/Popup.tsx @@ -1,11 +1,16 @@ import { Transition } from "@headlessui/react"; import { useEffect } from "react"; -import { HiOutlineExclamationCircle, HiXMark } from "react-icons/hi2"; +import { + HiOutlineCheckCircle, + HiOutlineExclamationCircle, + HiXMark, +} from "react-icons/hi2"; import { usePopup } from "~/providers/popup"; const Popup: React.FC = () => { - const { isOpen, popupHeader, popupMessage, hidePopup } = usePopup(); + const { isOpen, popupHeader, popupMessage, popupIcon, hidePopup } = + usePopup(); useEffect(() => { if (isOpen) { @@ -20,7 +25,7 @@ const Popup: React.FC = () => { return (
{
-

diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx new file mode 100644 index 00000000..623b8263 --- /dev/null +++ b/apps/web/src/components/ThemeToggle.tsx @@ -0,0 +1,27 @@ +import { CgDarkMode } from "react-icons/cg"; + +import { useTheme } from "~/providers/theme"; + +const ThemeToggle = () => { + const { themePreference, switchTheme } = useTheme(); + + const toggleTheme = () => { + switchTheme(themePreference === "light" ? "dark" : "light"); + }; + + return ( + + ); +}; + +export default ThemeToggle; diff --git a/apps/web/src/pages/[...workspaceSlug]/index.tsx b/apps/web/src/pages/[...workspaceSlug]/index.tsx deleted file mode 100644 index 89d04a52..00000000 --- a/apps/web/src/pages/[...workspaceSlug]/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import WorkspaceSlugView from "~/views/workspaceSlug"; - -export default function WorkspaceSlugPage() { - return ; -} diff --git a/apps/web/src/pages/[workspaceSlug]/[...boardSlug].tsx b/apps/web/src/pages/[workspaceSlug]/[...boardSlug].tsx new file mode 100644 index 00000000..9da732f5 --- /dev/null +++ b/apps/web/src/pages/[workspaceSlug]/[...boardSlug].tsx @@ -0,0 +1,5 @@ +import PublicBoardView from "~/views/public/board"; + +export default function PublicBoardsPage() { + return ; +} diff --git a/apps/web/src/pages/[workspaceSlug]/index.tsx b/apps/web/src/pages/[workspaceSlug]/index.tsx new file mode 100644 index 00000000..ba12f4e3 --- /dev/null +++ b/apps/web/src/pages/[workspaceSlug]/index.tsx @@ -0,0 +1,5 @@ +import PublicBoardsView from "~/views/public/boards"; + +export default function PublicBoardsPage() { + return ; +} diff --git a/apps/web/src/providers/board.tsx b/apps/web/src/providers/board.tsx index 1adceb2f..7c68b5be 100644 --- a/apps/web/src/providers/board.tsx +++ b/apps/web/src/providers/board.tsx @@ -1,12 +1,12 @@ import type { ReactNode } from "react"; import React, { createContext, useContext, useState } from "react"; -import { - type GetBoardByIdOutput, - type NewCardInput, - type NewListInput, - type ReorderCardInput, - type ReorderListInput, +import type { + GetBoardByIdOutput, + NewCardInput, + NewListInput, + ReorderCardInput, + ReorderListInput, } from "@kan/api/types"; import { generateUID } from "@kan/utils"; @@ -55,6 +55,7 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({ showPopup({ header: "Error fetching board", message: "Please try again later, or contact customer support.", + icon: "error", }); } }; @@ -68,6 +69,7 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({ showPopup({ header: "Unable to update card", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); @@ -81,6 +83,7 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({ showPopup({ header: "Unable to update list", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); diff --git a/apps/web/src/providers/popup.tsx b/apps/web/src/providers/popup.tsx index a61a0255..9f03bd8e 100644 --- a/apps/web/src/providers/popup.tsx +++ b/apps/web/src/providers/popup.tsx @@ -1,12 +1,17 @@ import { createContext, useContext, useState } from "react"; -type PopupContextType = { +interface PopupContextType { isOpen: boolean; - showPopup: (params: { header: string; message: string }) => void; + showPopup: (params: { + header: string; + message: string; + icon: string; + }) => void; hidePopup: () => void; popupHeader: string; popupMessage: string; -}; + popupIcon: string; +} interface Props { children: React.ReactNode; @@ -18,17 +23,21 @@ export const PopupProvider: React.FC = ({ children }) => { const [isOpen, setIsOpen] = useState(false); const [popupHeader, setPopupHeader] = useState(""); const [popupMessage, setPopupMessage] = useState(""); + const [popupIcon, setPopupIcon] = useState(""); const showPopup = ({ header, message, + icon, }: { header: string; message: string; + icon: string; }) => { setIsOpen(true); setPopupHeader(header); setPopupMessage(message); + setPopupIcon(icon); }; const hidePopup = () => { @@ -37,7 +46,14 @@ export const PopupProvider: React.FC = ({ children }) => { return ( {children} diff --git a/apps/web/src/views/board/components/DeleteListConfirmation.tsx b/apps/web/src/views/board/components/DeleteListConfirmation.tsx index 2601cf5e..2b81ddd1 100644 --- a/apps/web/src/views/board/components/DeleteListConfirmation.tsx +++ b/apps/web/src/views/board/components/DeleteListConfirmation.tsx @@ -1,9 +1,8 @@ -import { api } from "~/utils/api"; +import Button from "~/components/Button"; import { useBoard } from "~/providers/board"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; - -import Button from "~/components/Button"; +import { api } from "~/utils/api"; interface DeleteListConfirmationProps { listPublicId: string; @@ -38,6 +37,7 @@ export function DeleteListConfirmation({ showPopup({ header: "Unable to delete list", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); diff --git a/apps/web/src/views/board/components/Filters.tsx b/apps/web/src/views/board/components/Filters.tsx index 208fc0a1..8b714b6a 100644 --- a/apps/web/src/views/board/components/Filters.tsx +++ b/apps/web/src/views/board/components/Filters.tsx @@ -1,14 +1,16 @@ -import { IoFilterOutline } from "react-icons/io5"; -import { - HiOutlineUserCircle, - HiOutlineTag, - HiMiniXMark, -} from "react-icons/hi2"; import { useRouter } from "next/router"; +import { + HiMiniXMark, + HiOutlineTag, + HiOutlineUserCircle, +} from "react-icons/hi2"; +import { IoFilterOutline } from "react-icons/io5"; + +import type { GetBoardByIdOutput } from "@kan/api/types"; + import Button from "~/components/Button"; import CheckboxDropdown from "~/components/CheckboxDropdown"; import { formatToArray } from "~/utils/helpers"; -import { useBoard } from "~/providers/board"; const LabelIcon = ({ colourCode }: { colourCode: string | null }) => ( ( {name - ?.split(" ") + .split(" ") .map((namePart) => namePart.charAt(0).toUpperCase()) .join("")} ); -const Filters = () => { - const { boardData } = useBoard(); +const Filters = ({ + position = "right", + boardData, +}: { + position?: "left" | "right"; + boardData: GetBoardByIdOutput; +}) => { const router = useRouter(); const clearFilters = async (e: React.MouseEvent) => { @@ -51,7 +58,7 @@ const Filters = () => { }; const formattedMembers = - boardData?.workspace?.members?.map((member) => ({ + boardData?.workspace?.members.map((member) => ({ key: member.publicId, value: member.user?.name ?? "", selected: !!router.query.members?.includes(member.publicId), @@ -116,21 +123,24 @@ const Filters = () => { groups={groups} handleSelect={handleSelect} menuSpacing="md" + position={position} > - )} + {numOfFilters > 0 && ( + + )}

); diff --git a/apps/web/src/views/board/components/NewCardForm.tsx b/apps/web/src/views/board/components/NewCardForm.tsx index 420d2726..68a57978 100644 --- a/apps/web/src/views/board/components/NewCardForm.tsx +++ b/apps/web/src/views/board/components/NewCardForm.tsx @@ -6,7 +6,7 @@ import { HiXMark, } from "react-icons/hi2"; -import { type NewCardInput } from "@kan/api/types"; +import type { NewCardInput } from "@kan/api/types"; import Button from "~/components/Button"; import CheckboxDropdown from "~/components/CheckboxDropdown"; @@ -58,13 +58,14 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) { showPopup({ header: "Unable to create card", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); useEffect(() => { const titleElement: HTMLElement | null = - document?.querySelector("#title"); + document.querySelector("#title"); if (titleElement) titleElement.focus(); }, []); @@ -83,7 +84,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) { })) ?? []; const formattedMembers = - boardData?.workspace?.members?.map((member) => ({ + boardData?.workspace?.members.map((member) => ({ key: member.publicId, value: member.user?.name ?? "", selected: memberPublicIds.includes(member.publicId), @@ -207,7 +208,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) { > {member?.value - ?.split(" ") + .split(" ") .map((namePart) => namePart.charAt(0).toUpperCase(), ) diff --git a/apps/web/src/views/board/components/NewListForm.tsx b/apps/web/src/views/board/components/NewListForm.tsx index 2a11229d..a6cbca67 100644 --- a/apps/web/src/views/board/components/NewListForm.tsx +++ b/apps/web/src/views/board/components/NewListForm.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { HiXMark } from "react-icons/hi2"; -import { type NewListInput } from "@kan/api/types"; +import type { NewListInput } from "@kan/api/types"; import Button from "~/components/Button"; import Input from "~/components/Input"; @@ -42,13 +42,14 @@ export function NewListForm({ boardPublicId }: { boardPublicId: string }) { showPopup({ header: "Unable to create list", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); useEffect(() => { const nameElement: HTMLElement | null = - document?.querySelector("#list-name"); + document.querySelector("#list-name"); if (nameElement) nameElement.focus(); }, []); diff --git a/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx b/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx index 087c52b3..82eb5365 100644 --- a/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx +++ b/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx @@ -60,6 +60,7 @@ export function UpdateBoardSlugForm({ showPopup({ header: "Unable to update board URL", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 16bfb5da..31620087 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -161,7 +161,7 @@ export default function BoardPage() { )}
- + + ); + }; + + return ( + <> + + + +
+
+ +
+ {isLoading ? ( +
+
+
+ ) : ( +

+ {data?.name} +

+ )} +
+ +
+
+ +
+ {isLoading ? ( +
+
+
+
+
+ ) : ( +
+
+ {data?.lists.map((list) => ( +
+
+ + {list.name} + +
+
+ {list.cards.map((card) => ( + +
{card.title}
+ {card.labels.length || card.members.length ? ( +
+ {card.labels.map((label) => ( + + +
{label.name}
+
+ ))} +
+ {card.members.map((member) => ( + + + {member.user?.name + ?.split(" ") + .map((namePart) => + namePart.charAt(0).toUpperCase(), + ) + .join("")} + + + ))} +
+
+ ) : null} + + ))} +
+
+ ))} +
+
+ )} +
+
+
+
+ + +
+ + + kan.bn + +
+
+ + + ); +} diff --git a/apps/web/src/views/workspaceSlug/index.tsx b/apps/web/src/views/public/boards/index.tsx similarity index 98% rename from apps/web/src/views/workspaceSlug/index.tsx rename to apps/web/src/views/public/boards/index.tsx index a7902434..e5bcf7b7 100644 --- a/apps/web/src/views/workspaceSlug/index.tsx +++ b/apps/web/src/views/public/boards/index.tsx @@ -5,7 +5,7 @@ import { PageHead } from "~/components/PageHead"; import PatternedBackground from "~/components/PatternedBackground"; import { api } from "~/utils/api"; -export default function WorkspaceSlugPage() { +export default function PublicBoardsView() { const router = useRouter(); const workspaceSlug = Array.isArray(router.query.workspaceSlug) diff --git a/apps/web/src/views/settings/components/DeleteWorkspaceConfirmation.tsx b/apps/web/src/views/settings/components/DeleteWorkspaceConfirmation.tsx index adb64a39..97603c70 100644 --- a/apps/web/src/views/settings/components/DeleteWorkspaceConfirmation.tsx +++ b/apps/web/src/views/settings/components/DeleteWorkspaceConfirmation.tsx @@ -1,12 +1,11 @@ -import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useState } from "react"; -import { api } from "~/utils/api"; +import Button from "~/components/Button"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { useWorkspace } from "~/providers/workspace"; - -import Button from "~/components/Button"; +import { api } from "~/utils/api"; export function DeleteWorkspaceConfirmation() { const { closeModal } = useModal(); @@ -20,7 +19,7 @@ export function DeleteWorkspaceConfirmation() { onSuccess: () => { closeModal(); const filteredWorkspaces = availableWorkspaces.filter( - (ws) => ws.publicId !== workspace?.publicId, + (ws) => ws.publicId !== workspace.publicId, ); if (filteredWorkspaces.length > 0 && filteredWorkspaces[0]) { switchWorkspace(filteredWorkspaces[0]); @@ -33,13 +32,14 @@ export function DeleteWorkspaceConfirmation() { showPopup({ header: "Error deleting workspace", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); const handleDeleteWorkspace = () => { deleteWorkspaceMutation.mutate({ - workspacePublicId: workspace?.publicId, + workspacePublicId: workspace.publicId, }); }; @@ -47,7 +47,7 @@ export function DeleteWorkspaceConfirmation() {

- {`Are you sure you want to delete the workspace ${workspace?.name}?`} + {`Are you sure you want to delete the workspace ${workspace.name}?`}

Keep in mind that this action is irreversible. diff --git a/apps/web/src/views/settings/components/UpdateWorkspaceDescriptionForm.tsx b/apps/web/src/views/settings/components/UpdateWorkspaceDescriptionForm.tsx index 63e53a96..3445f239 100644 --- a/apps/web/src/views/settings/components/UpdateWorkspaceDescriptionForm.tsx +++ b/apps/web/src/views/settings/components/UpdateWorkspaceDescriptionForm.tsx @@ -53,6 +53,7 @@ const UpdateWorkspaceDescriptionForm = ({ showPopup({ header: "Error updating workspace description", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); diff --git a/apps/web/src/views/settings/components/UpdateWorkspaceNameForm.tsx b/apps/web/src/views/settings/components/UpdateWorkspaceNameForm.tsx index 28d3e1ba..7f4281b2 100644 --- a/apps/web/src/views/settings/components/UpdateWorkspaceNameForm.tsx +++ b/apps/web/src/views/settings/components/UpdateWorkspaceNameForm.tsx @@ -1,8 +1,9 @@ -import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; import { z } from "zod"; -import Input from "~/components/Input"; + import Button from "~/components/Button"; +import Input from "~/components/Input"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -48,6 +49,7 @@ const UpdateWorkspaceNameForm = ({ showPopup({ header: "Error updating workspace name", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); diff --git a/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx b/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx index 288871f9..2ea989b8 100644 --- a/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx +++ b/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx @@ -64,6 +64,7 @@ const UpdateWorkspaceUrlForm = ({ showPopup({ header: "Error updating workspace username", message: "Please try again later, or contact customer support.", + icon: "error", }); }, }); diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 2c507f68..10ca53a2 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -7,7 +7,7 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo"; import * as listRepo from "@kan/db/repository/list.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const boardRouter = createTRPCRouter({ all: protectedProcedure @@ -70,6 +70,37 @@ export const boardRouter = createTRPCRouter({ }, ); + return result; + }), + bySlug: publicProcedure + .meta({ + openapi: { + method: "GET", + path: "/board/{boardSlug}", + summary: "Get board by slug", + description: "Retrieves a board by its slug", + tags: ["Boards"], + protect: true, + }, + }) + .input( + z.object({ + boardSlug: z + .string() + .min(3) + .max(24) + .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), + members: z.array(z.string().min(12)).optional(), + labels: z.array(z.string().min(12)).optional(), + }), + ) + .output(z.custom>>()) + .query(async ({ ctx, input }) => { + const result = await boardRepo.getBySlug(ctx.db, input.boardSlug, { + members: input.members ?? [], + labels: input.labels ?? [], + }); + return result; }), create: protectedProcedure diff --git a/packages/api/src/routers/workspace.ts b/packages/api/src/routers/workspace.ts index 7ad45aea..b30ff9a6 100644 --- a/packages/api/src/routers/workspace.ts +++ b/packages/api/src/routers/workspace.ts @@ -71,7 +71,7 @@ export const workspaceRouter = createTRPCRouter({ openapi: { summary: "Get a workspace by slug", method: "GET", - path: "/workspaces/slug/{workspaceSlug}", + path: "/workspaces/{workspaceSlug}", description: "Retrieves a workspace by its slug", tags: ["Workspaces"], protect: true, diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index 93f40e09..2a9968d7 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -95,6 +95,88 @@ export const getByPublicId = async ( return data; }; +export const getBySlug = async ( + db: SupabaseClient, + boardSlug: string, + filters: { + members: string[]; + labels: string[]; + }, +) => { + let query = db + .from("board") + .select( + ` + publicId, + name, + slug, + workspace ( + publicId, + name, + slug, + description, + members:workspace_members ( + publicId, + user!workspace_members_userId_user_id_fk ( + name + ) + ) + ), + labels:label ( + publicId, + name, + colourCode + ), + lists:list ( + publicId, + name, + boardId, + index, + cards:card ( + publicId, + title, + description, + listId, + index, + labels:label${filters.labels.length > 0 ? "!inner" : ""} ( + publicId, + name, + colourCode + ), + members:workspace_members${filters.members.length > 0 ? "!inner" : ""} ( + publicId, + user!workspace_members_userId_user_id_fk ( + name + ) + ) + ) + ) + `, + ) + .eq("slug", boardSlug) + .is("deletedAt", null) + .is("lists.deletedAt", null) + .is("lists.cards.deletedAt", null) + .is("workspace.members.deletedAt", null) + .is("lists.cards.members.deletedAt", null); + + if (filters.labels.length > 0) { + query = query.in("lists.cards.labels.publicId", filters.labels); + } + + if (filters.members.length > 0) { + query = query.in("lists.cards.members.publicId", filters.members); + } + + const { data } = await query + .order("index", { foreignTable: "list", ascending: true }) + .order("index", { foreignTable: "list.card", ascending: true }) + .limit(1) + .single(); + + return data; +}; + export const getWithListIdsByPublicId = async ( db: SupabaseClient, boardPublicId: string, diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index cb896e64..0dd0c6e5 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -19,7 +19,7 @@ export const create = async ( slug: workspaceInput.name.toLowerCase(), createdBy: workspaceInput.createdBy, }) - .select(`id, publicId, name`) + .select(`id, publicId, name, slug, description, plan`) .limit(1) .single();