Merge pull request #926 from ahmadk953/liveblocks

Implement basic Liveblocks integration and update README
This commit is contained in:
Ahmad 2025-01-11 01:11:11 -05:00 committed by GitHub
commit 6d14c8f43f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 336 additions and 15 deletions

View file

@ -22,19 +22,20 @@ Documentation can be found [here](https://docs.tasko.ahmadk953.org/).
This will be published on the site some time soon but for now, the roadmap will be listed here. This will be published on the site some time soon but for now, the roadmap will be listed here.
- [x] Finish adding started at date field
- [x] Pagination for Audit Logs page
- [ ] Board sorting options (Boards Page) - [ ] Board sorting options (Boards Page)
- [ ] Add real-time collaboration - [ ] Add real-time collaboration _In Progress - Liveblocks with presence implemented roughly_
- [ ] Add end-to-end Database encryption (for customer data such as card titles and descriptions, and subscription information) - [ ] Add end-to-end Database encryption (for customer data such as card titles and descriptions, and subscription information)
- [ ] Add dark mode - [ ] Add dark mode _In Progress_
- [ ] Markdown Support in Card Descriptions - [ ] Rich Text Support in Card Descriptions
- [ ] Self-Hosted Version - [ ] Self-Hosted Version
- [ ] Move Roadmap to Website _In Progress - Starting off With a Basic MDX Page_
## Contributing and Development ## Contributing and Development
### Development Commands ### Development Commands
Install Dependencies: ``yarn install --immutable``
Start Dev Server: ``yarn dev`` Start Dev Server: ``yarn dev``
Production Build: ``yarn build`` Production Build: ``yarn build``
@ -47,12 +48,16 @@ Check Formatting: ``yarn format``
Fix Formatting: ``yarn format:fix`` Fix Formatting: ``yarn format:fix``
Please make sure to lint and check formatting (using the commands listed above) before submitting a Pull Request. Testing: ``yarn test``
Coverage: ``yarn coverage``
Please make sure to lint, check formatting, and test (using the commands listed above) before submitting a Pull Request.
## Legal ## Legal
Privacy Policy _Temporarily removed to revamp it._ Privacy Policy _Will be added back soon_
Terms of Service _The Terms of Service will be added soon!_ Terms of Service _Will be added along with privacy policy_
[License](https://github.com/ahmadk953/tasko/blob/main/LICENCE) _Will be located on website at some point in time._ [License](https://github.com/ahmadk953/tasko/blob/main/LICENCE) _Will be added onto the website with privacy policy and terms of service_

View file

@ -0,0 +1,68 @@
'use client';
import {
ClientSideSuspense,
LiveblocksProvider,
RoomProvider,
} from '@liveblocks/react';
import { Skeleton } from '@/components/ui/skeleton';
export const BoardLiveblocks = ({
children,
boardId,
}: {
children: React.ReactNode;
boardId: string;
}) => {
return (
<LiveblocksProvider
authEndpoint={async (room) => {
const headers = {
'Content-Type': 'application/json',
BoardId: `${boardId}`,
};
const body = JSON.stringify({
room,
});
const response = await fetch('/api/liveblocks-auth', {
method: 'POST',
headers,
body,
});
return await response.json();
}}
throttle={16}
>
<RoomProvider id={`${boardId}`} initialPresence={{ cursor: null }}>
<ClientSideSuspense fallback={<BoardLiveblocks.Skeleton />}>
{children}
</ClientSideSuspense>
</RoomProvider>
</LiveblocksProvider>
);
};
BoardLiveblocks.Skeleton = function SkeletonBoardLiveblocks() {
return (
<div className='h-full overflow-x-auto p-4'>
<ol className='flex h-full gap-x-3'>
<li className='h-full w-[272px] shrink-0 select-none'>
<div className='w-full rounded-md bg-[#f1f2f4] pb-2 shadow-md'>
<div className='flex items-start justify-between gap-x-2 px-2 pt-2 text-sm font-semibold'>
<Skeleton className='h-7 truncate border-transparent bg-transparent px-[7px] py-1 text-sm font-medium' />
</div>
<ol className='mx-1 mt-2 flex flex-col gap-y-2 px-1 py-0.5'>
<Skeleton className='h-12 space-y-2 truncate rounded-md border-2 border-transparent bg-white px-3 py-2 text-sm shadow-sm' />
<Skeleton className='h-24 space-y-2 truncate rounded-md border-2 border-transparent bg-white px-3 py-2 text-sm shadow-sm' />
<Skeleton className='h-16 space-y-2 truncate rounded-md border-2 border-transparent bg-white px-3 py-2 text-sm shadow-sm' />
</ol>
</div>
</li>
<div className='w-1 flex-shrink-0' />
</ol>
</div>
);
};

View file

@ -7,7 +7,7 @@ interface BoardNavbarProps {
data: Board; data: Board;
} }
export const BoardNavbar = async ({ data }: BoardNavbarProps) => { export const BoardNavbar = ({ data }: BoardNavbarProps) => {
return ( return (
<div className='fixed top-14 z-[40] flex h-14 w-full items-center gap-x-4 bg-black/50 px-6 text-white'> <div className='fixed top-14 z-[40] flex h-14 w-full items-center gap-x-4 bg-black/50 px-6 text-white'>
<BoardTitleForm data={data} /> <BoardTitleForm data={data} />

View file

@ -0,0 +1,74 @@
'use client';
import { useOthers, useMyPresence } from '@liveblocks/react/suspense';
import { Cursor } from './cursor';
import { colors } from '@/constants/colors';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export const BoardRoomWrapper = ({
children,
}: {
children: React.ReactNode;
}) => {
const others = useOthers();
const ref = useRef<HTMLDivElement>(null);
const [numbers, setNumbers] = useState<number[]>([]);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [myPresence, updateMyPresence] = useMyPresence();
useLayoutEffect(() => {
if (ref.current) {
setWidth(ref.current.clientWidth);
setHeight(ref.current.clientHeight);
}
}, [numbers]);
useEffect(() => {
function handleWindowResize() {
if (ref.current) {
setWidth(ref.current.clientWidth);
setHeight(ref.current.clientHeight);
}
}
window.addEventListener('resize', handleWindowResize);
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, []);
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const normalizedCursorX = (e.clientX - rect.left) / rect.width;
const normalizedCursorY = (e.clientY - rect.top) / rect.height;
updateMyPresence({
cursor: { x: normalizedCursorX, y: normalizedCursorY },
});
}
function handlePointerLeave(e: React.PointerEvent<HTMLDivElement>) {
updateMyPresence({ cursor: null });
}
return (
<div
className='relative h-full w-full'
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
ref={ref}
>
{children}
{others.map(({ connectionId, presence }) => (
<Cursor
key={connectionId}
x={(presence.cursor?.x as number) * width}
y={(presence.cursor?.y as number) * height}
color={colors[connectionId % colors.length]}
/>
))}
</div>
);
};

View file

@ -0,0 +1,28 @@
interface CursorProps {
x: number | undefined;
y: number | undefined;
color: string;
}
export function Cursor({ x, y, color }: CursorProps) {
return (
<svg
style={{
position: 'absolute',
left: 0,
top: 0,
transform: `translateX(${x}px) translateY(${y}px)`,
}}
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='m13.67 6.03-11-4a.5.5 0 0 0-.64.64l4 11a.5.5 0 0 0 .935.015l1.92-4.8 4.8-1.92a.5.5 0 0 0 0-.935h-.015Z'
fill={`${color}`}
/>
</svg>
);
}

View file

@ -1,8 +1,15 @@
import { auth } from '@clerk/nextjs/server'; import { auth } from '@clerk/nextjs/server';
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import {
LiveblocksProvider,
RoomProvider,
ClientSideSuspense,
} from '@liveblocks/react/suspense';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { BoardNavbar } from './_components/board-navbar'; import { BoardNavbar } from './_components/board-navbar';
import { Skeleton } from '@/components/ui/skeleton';
import { BoardLiveblocks } from './_components/board-liveblocks';
export async function generateMetadata(props: { export async function generateMetadata(props: {
params: Promise<{ boardId: string }>; params: Promise<{ boardId: string }>;
@ -53,7 +60,9 @@ const BoardIdLayout = async (props: {
> >
<BoardNavbar data={board} /> <BoardNavbar data={board} />
<div className='absolute inset-0 bg-black/10' /> <div className='absolute inset-0 bg-black/10' />
<main className='relative h-full pt-28'>{children}</main> <main className='relative h-full pt-28'>
<BoardLiveblocks boardId={params.boardId}>{children}</BoardLiveblocks>
</main>
</div> </div>
); );
}; };

View file

@ -3,6 +3,7 @@ import { redirect } from 'next/navigation';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ListContainer } from './_components/list-container'; import { ListContainer } from './_components/list-container';
import { BoardRoomWrapper } from './_components/board-room-wrapper';
interface BoardIdPageProps { interface BoardIdPageProps {
params: Promise<{ params: Promise<{
@ -38,9 +39,11 @@ const BoardIdPage = async (props: BoardIdPageProps) => {
}); });
return ( return (
<div className='h-full overflow-x-auto p-4'> <BoardRoomWrapper>
<ListContainer boardId={params.boardId} data={lists} /> <div className='h-full overflow-x-auto p-4'>
</div> <ListContainer boardId={params.boardId} data={lists} />
</div>
</BoardRoomWrapper>
); );
}; };

View file

@ -0,0 +1,39 @@
import { db } from '@/lib/db';
import { auth, currentUser } from '@clerk/nextjs/server';
import { Liveblocks } from '@liveblocks/node';
import { headers } from 'next/headers';
export async function POST(req: Request) {
const { sessionClaims } = await auth();
const user = await currentUser();
if (!sessionClaims || !user) {
return new Response('Not authorized', { status: 401 });
}
const { room } = await req.json();
const boardId = (await headers()).get('BoardId') as string;
const board = await db.board.findUnique({
where: {
id: boardId,
orgId: sessionClaims.org_id,
},
});
if (!board || board.orgId !== sessionClaims.org_id) {
return new Response('Not authorized', { status: 401 });
}
const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});
const session = liveblocks.prepareSession(user.id, {
userInfo: {
name: user.fullName!,
avatar: user.imageUrl,
},
});
session.allow(room, session.FULL_ACCESS);
const { body, status } = await session.authorize();
return new Response(body, { status });
}

10
constants/colors.ts Normal file
View file

@ -0,0 +1,10 @@
export const colors = [
'#E57373',
'#9575CD',
'#4FC3F7',
'#81C784',
'#FFF176',
'#FF8A65',
'#F06292',
'#7986CB',
];

25
liveblocks.config.ts Normal file
View file

@ -0,0 +1,25 @@
declare global {
interface Liveblocks {
Presence: {
cursor: { x: number; y: number } | null;
};
Storage: {};
UserMeta: {
id: string;
info: {
name: string;
avatar: string;
};
};
RoomEvent: {};
ThreadMetadata: {};
RoomInfo: {};
}
}
export {};

View file

@ -19,6 +19,9 @@
"@arcjet/next": "^1.0.0-alpha.34", "@arcjet/next": "^1.0.0-alpha.34",
"@clerk/nextjs": "^6.9.9", "@clerk/nextjs": "^6.9.9",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^17.0.0",
"@liveblocks/client": "^2.15.1",
"@liveblocks/node": "^2.15.1",
"@liveblocks/react": "^2.15.1",
"@mdx-js/loader": "^3.1.0", "@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0", "@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.1.4", "@next/mdx": "^15.1.4",

View file

@ -1774,6 +1774,46 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@liveblocks/client@npm:2.15.2, @liveblocks/client@npm:^2.15.1":
version: 2.15.2
resolution: "@liveblocks/client@npm:2.15.2"
dependencies:
"@liveblocks/core": "npm:2.15.2"
checksum: 10c0/a9ea5ab865b0d1afada8670e3f10ab605f03da2e106e35dc40edbacec257462e723f48f1a0ce8cd1d74182b673fb934f13b14ad900805630072a0952f62d41c8
languageName: node
linkType: hard
"@liveblocks/core@npm:2.15.2":
version: 2.15.2
resolution: "@liveblocks/core@npm:2.15.2"
checksum: 10c0/eadef786899051ab6ceaa9f33020ef6f422bcc1abf4e504bbd8fb724d7ad4f3a97a4e9420cfc9b2988fa6ed71c17781a030dc69b81cb1d4420469d41d2b2fb48
languageName: node
linkType: hard
"@liveblocks/node@npm:^2.15.1":
version: 2.15.2
resolution: "@liveblocks/node@npm:2.15.2"
dependencies:
"@liveblocks/core": "npm:2.15.2"
"@stablelib/base64": "npm:^1.0.1"
fast-sha256: "npm:^1.3.0"
node-fetch: "npm:^2.6.1"
checksum: 10c0/a5b13f2351218f1ba088c867963fd81669b3c13948651177f7186f84a7f66551246c7d8d002938a490c7a3e3ca2f4f2669c405c2141d92a5e64515166e8338b4
languageName: node
linkType: hard
"@liveblocks/react@npm:^2.15.1":
version: 2.15.2
resolution: "@liveblocks/react@npm:2.15.2"
dependencies:
"@liveblocks/client": "npm:2.15.2"
"@liveblocks/core": "npm:2.15.2"
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
checksum: 10c0/ae1ba5473bd555e60e8c8ede1d62f38cdd029cc97f216c3bf765085f2c6f679ca61e5870b06407ac35be52e026af54b19bee7d3d9ccbfc49bde459a5c43e6e2f
languageName: node
linkType: hard
"@mdx-js/esbuild@npm:^3.0.0": "@mdx-js/esbuild@npm:^3.0.0":
version: 3.1.0 version: 3.1.0
resolution: "@mdx-js/esbuild@npm:3.1.0" resolution: "@mdx-js/esbuild@npm:3.1.0"
@ -3877,6 +3917,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@stablelib/base64@npm:^1.0.1":
version: 1.0.1
resolution: "@stablelib/base64@npm:1.0.1"
checksum: 10c0/6330720f021819d19cecfe274111b79a256caa81df478d6b0ae7effc8842b96915b6aeed85926ff05b4d48ec1fc78ad043d928b730ee4e6cc6e8cba6aa097bed
languageName: node
linkType: hard
"@swc/counter@npm:0.1.3": "@swc/counter@npm:0.1.3":
version: 0.1.3 version: 0.1.3
resolution: "@swc/counter@npm:0.1.3" resolution: "@swc/counter@npm:0.1.3"
@ -7059,6 +7106,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"fast-sha256@npm:^1.3.0":
version: 1.3.0
resolution: "fast-sha256@npm:1.3.0"
checksum: 10c0/87f9e4baa7639576cf60a2b6235c9f436e1a1c52323abbd8a705b5bea8355500acf176f2aed0c14f2ecd6d6007e26151461bab2f27b8953bcca8d9d6b76a86e4
languageName: node
linkType: hard
"fastq@npm:^1.6.0": "fastq@npm:^1.6.0":
version: 1.18.0 version: 1.18.0
resolution: "fastq@npm:1.18.0" resolution: "fastq@npm:1.18.0"
@ -10077,7 +10131,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"node-fetch@npm:^2.6.7": "node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7":
version: 2.7.0 version: 2.7.0
resolution: "node-fetch@npm:2.7.0" resolution: "node-fetch@npm:2.7.0"
dependencies: dependencies:
@ -12419,6 +12473,9 @@ __metadata:
"@eslint/eslintrc": "npm:^3.1.0" "@eslint/eslintrc": "npm:^3.1.0"
"@eslint/js": "npm:^9.16.0" "@eslint/js": "npm:^9.16.0"
"@hello-pangea/dnd": "npm:^17.0.0" "@hello-pangea/dnd": "npm:^17.0.0"
"@liveblocks/client": "npm:^2.15.1"
"@liveblocks/node": "npm:^2.15.1"
"@liveblocks/react": "npm:^2.15.1"
"@mdx-js/loader": "npm:^3.1.0" "@mdx-js/loader": "npm:^3.1.0"
"@mdx-js/react": "npm:^3.1.0" "@mdx-js/react": "npm:^3.1.0"
"@microsoft/eslint-formatter-sarif": "npm:^3.1.0" "@microsoft/eslint-formatter-sarif": "npm:^3.1.0"