Skip to content

Commit bc7c604

Browse files
committed
initial commit
1 parent e555236 commit bc7c604

File tree

429 files changed

+27263
-2
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

429 files changed

+27263
-2
lines changed

Diff for: .babelrc

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"presets": ["next/babel"],
3+
// "superjson-next" plugin uses superjson for serialization between getServerSideProps and client,
4+
// so that types like Date and BigInt are properly handled
5+
"plugins": ["superjson-next"]
6+
}

Diff for: .env

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
NEXTAUTH_SECRET=abc123
2+
DATABASE_URL="postgresql://postgres:abc123@localhost:5432/todo?schema=public"
3+
GITHUB_ID=
4+
GITHUB_SECRET=

Diff for: .eslintrc.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "next/core-web-vitals"
3+
}

Diff for: .github/workflows/build.yml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3+
4+
name: CI
5+
6+
env:
7+
DO_NOT_TRACK: '1'
8+
9+
on:
10+
push:
11+
branches: ['main']
12+
pull_request:
13+
branches: ['main']
14+
15+
jobs:
16+
build:
17+
runs-on: ubuntu-latest
18+
19+
strategy:
20+
matrix:
21+
node-version: [16.x]
22+
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
23+
24+
steps:
25+
- uses: actions/checkout@v3
26+
- name: Use Node.js ${{ matrix.node-version }}
27+
uses: actions/setup-node@v3
28+
with:
29+
node-version: ${{ matrix.node-version }}
30+
cache: 'npm'
31+
- run: npm ci
32+
- run: npm run lint
33+
- run: npm run build

Diff for: .gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
logs
2+
*.log
3+
node_modules/
4+
.env.local
5+
.next
6+
.DS_Store

Diff for: README.md

+45-2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,45 @@
1-
# sample-todo-trpc
2-
A complete Todo app sample built with ZenStack, tRPC, and Next.js
1+
# A Collaborative Todo Sample
2+
3+
This project is a collaborative todo app built with [tRPC](https://trpc.io], [Next.js](https://nextjs.org), [Next-Auth](nextauth.org), and [ZenStack](https://zenstack.dev).
4+
5+
In this fictitious app, users can be invited to workspaces where they can collaborate on todos. Public todo lists are visible to all members in the workspace.
6+
7+
See a live deployment at: https://zenstack-todo.vercel.app/.
8+
9+
## Features:
10+
11+
- User signup/signin
12+
- Creating workspaces and inviting members
13+
- Data segregation and permission control
14+
15+
## Running the sample:
16+
17+
1. Setup a new PostgreSQL database
18+
19+
You can launch a PostgreSQL instance locally, or create one from a hoster like [Supabase](https://supabase.com). Create a new database for this app, and set the connection string in .env file.
20+
21+
1. Install dependencies
22+
23+
```bash
24+
npm install
25+
```
26+
27+
1. Generate server and client-side code from model
28+
29+
```bash
30+
npm run generate
31+
```
32+
33+
1. Synchronize database schma
34+
35+
```bash
36+
npm run db:push
37+
```
38+
39+
1. Start dev server
40+
41+
```bash
42+
npm run dev
43+
```
44+
45+
For more information on using ZenStack, visit [https://zenstack.dev](https://zenstack.dev).

Diff for: components/AuthGuard.tsx

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useSession } from 'next-auth/react';
2+
import { useRouter } from 'next/router';
3+
4+
type Props = {
5+
children: JSX.Element | JSX.Element[];
6+
};
7+
8+
export default function AuthGuard({ children }: Props) {
9+
const { status } = useSession();
10+
const router = useRouter();
11+
12+
if (router.pathname === '/signup' || router.pathname === '/signin') {
13+
return <>{children}</>;
14+
}
15+
16+
if (status === 'loading') {
17+
return <p>Loading...</p>;
18+
} else if (status === 'unauthenticated') {
19+
router.push('/signin');
20+
return <></>;
21+
} else {
22+
return <>{children}</>;
23+
}
24+
}

Diff for: components/Avatar.tsx

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { User } from 'next-auth';
2+
import Image from 'next/image';
3+
4+
type Props = {
5+
user: User;
6+
size?: number;
7+
};
8+
9+
export default function Avatar({ user, size }: Props) {
10+
if (!user) {
11+
return <></>;
12+
}
13+
return (
14+
<div className="tooltip" data-tip={user.name || user.email}>
15+
<Image
16+
src={user.image || '/avatar.jpg'}
17+
alt="avatar"
18+
width={size || 32}
19+
height={size || 32}
20+
className="rounded-full"
21+
/>
22+
</div>
23+
);
24+
}

Diff for: components/BreadCrumb.tsx

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { List, Space } from '@prisma/client';
2+
import Link from 'next/link';
3+
import { useRouter } from 'next/router';
4+
5+
type Props = {
6+
space: Space;
7+
list?: List;
8+
};
9+
10+
export default function BreadCrumb({ space, list }: Props) {
11+
const router = useRouter();
12+
13+
const parts = router.asPath.split('/').filter((p) => p);
14+
const [base] = parts;
15+
if (base !== 'space') {
16+
return <></>;
17+
}
18+
19+
const items: Array<{ text: string; link: string }> = [];
20+
21+
items.push({ text: 'Home', link: '/' });
22+
items.push({ text: space.name || '', link: `/space/${space.slug}` });
23+
24+
if (list) {
25+
items.push({
26+
text: list?.title || '',
27+
link: `/space/${space.slug}/${list.id}`,
28+
});
29+
}
30+
31+
return (
32+
<div className="text-sm text-gray-600 breadcrumbs">
33+
<ul>
34+
{items.map((item, i) => (
35+
<li key={i}>
36+
<Link href={item.link}>
37+
<a>{item.text}</a>
38+
</Link>
39+
</li>
40+
))}
41+
</ul>
42+
</div>
43+
);
44+
}

Diff for: components/ManageMembers.tsx

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
2+
import { useCurrentUser } from '@lib/context';
3+
import { isTRPCClientError, trpc } from '@lib/trpc';
4+
import { Space, SpaceUser, SpaceUserRole, User } from '@prisma/client';
5+
import { inferProcedureOutput } from '@trpc/server';
6+
import { ChangeEvent, KeyboardEvent, useState } from 'react';
7+
import { toast } from 'react-toastify';
8+
import Avatar from './Avatar';
9+
10+
type Props = {
11+
space: Space;
12+
};
13+
14+
export default function ManageMembers({ space }: Props) {
15+
const [email, setEmail] = useState('');
16+
const [role, setRole] = useState<SpaceUserRole>(SpaceUserRole.USER);
17+
const user = useCurrentUser();
18+
19+
const { data: members } = trpc.spaceUser.findMany.useQuery<
20+
inferProcedureOutput<typeof trpc.spaceUser.findMany>,
21+
// a cast is needed because trpc's procedure typing is static
22+
(SpaceUser & { user: User })[]
23+
>({
24+
where: {
25+
spaceId: space.id,
26+
},
27+
include: {
28+
user: true,
29+
},
30+
orderBy: {
31+
role: 'desc',
32+
},
33+
});
34+
35+
const { mutateAsync: addMember } = trpc.spaceUser.create.useMutation();
36+
const { mutateAsync: delMember } = trpc.spaceUser.delete.useMutation();
37+
38+
const inviteUser = async () => {
39+
try {
40+
const r = await addMember({
41+
data: {
42+
user: {
43+
connect: {
44+
email,
45+
},
46+
},
47+
space: {
48+
connect: {
49+
id: space.id,
50+
},
51+
},
52+
role,
53+
},
54+
});
55+
console.log('SpaceUser created:', r);
56+
} catch (err: any) {
57+
console.error(err);
58+
if (isTRPCClientError(err) && err.data?.prismaError) {
59+
console.error('PrismaError:', err.data.prismaError);
60+
if (err.data.prismaError.code === 'P2002') {
61+
toast.error('User is already a member of the space');
62+
} else if (err.data.prismaError.code === 'P2025') {
63+
toast.error('User is not found for this email');
64+
} else {
65+
toast.error(`Unexpected Prisma error: ${err.data.prismaError.code}`);
66+
}
67+
} else {
68+
toast.error(`Error occurred: ${JSON.stringify(err)}`);
69+
}
70+
}
71+
};
72+
73+
const removeMember = async (id: string) => {
74+
if (confirm(`Are you sure to remove this member from space?`)) {
75+
await delMember({ where: { id } });
76+
}
77+
};
78+
79+
return (
80+
<div>
81+
<div className="flex flex-wrap gap-2 items-center mb-8 w-full">
82+
<input
83+
type="text"
84+
placeholder="Type user email and enter to invite"
85+
className="input input-sm input-bordered flex-grow mr-2"
86+
value={email}
87+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
88+
setEmail(e.currentTarget.value);
89+
}}
90+
onKeyUp={(e: KeyboardEvent<HTMLInputElement>) => {
91+
if (e.key === 'Enter') {
92+
inviteUser();
93+
}
94+
}}
95+
/>
96+
97+
<select
98+
className="select select-sm mr-2"
99+
value={role}
100+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
101+
setRole(e.currentTarget.value as SpaceUserRole);
102+
}}
103+
>
104+
<option value={SpaceUserRole.USER}>USER</option>
105+
<option value={SpaceUserRole.ADMIN}>ADMIN</option>
106+
</select>
107+
108+
<button onClick={() => inviteUser()}>
109+
<PlusIcon className="w-6 h-6 text-gray-500" />
110+
</button>
111+
</div>
112+
113+
<ul className="space-y-2">
114+
{members?.map((member) => (
115+
<li key={member.id} className="flex flex-wrap w-full justify-between">
116+
<div className="flex items-center">
117+
<div className="hidden md:block mr-2">
118+
<Avatar user={member.user} size={32} />
119+
</div>
120+
<p className="w-36 md:w-48 line-clamp-1 mr-2">{member.user.name || member.user.email}</p>
121+
<p>{member.role}</p>
122+
</div>
123+
<div className="flex items-center">
124+
{user?.id !== member.user.id && (
125+
<TrashIcon
126+
className="w-4 h-4 text-gray-500"
127+
onClick={() => {
128+
removeMember(member.id);
129+
}}
130+
/>
131+
)}
132+
</div>
133+
</li>
134+
))}
135+
</ul>
136+
</div>
137+
);
138+
}

Diff for: components/NavBar.tsx

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Space } from '@prisma/client';
2+
import { User } from 'next-auth';
3+
import { signOut } from 'next-auth/react';
4+
import Image from 'next/image';
5+
import Link from 'next/link';
6+
import Avatar from './Avatar';
7+
8+
type Props = {
9+
space: Space | undefined;
10+
user: User | undefined;
11+
};
12+
13+
export default function NavBar({ user, space }: Props) {
14+
const onSignout = async () => {
15+
await signOut({ callbackUrl: '/signin' });
16+
};
17+
18+
return (
19+
<div className="navbar bg-base-100 px-8 py-2 border-b">
20+
<div className="flex-1">
21+
<Link href="/">
22+
<a className="flex items-center">
23+
<Image src="/logo.png" alt="Logo" width={32} height={32} />
24+
<div className="text-xl font-semibold ml-2 text-slate-700 hidden md:inline-block">
25+
{space?.name || 'Welcome Todo App'}
26+
</div>
27+
<p className="text-xs ml-2 text-gray-500 self-end">Powered by ZenStack</p>
28+
</a>
29+
</Link>
30+
</div>
31+
<div className="flex-none">
32+
<div className="dropdown dropdown-end">
33+
<label tabIndex={0} className="btn btn-ghost btn-circle avatar">
34+
{user && <Avatar user={user!} />}
35+
</label>
36+
<ul
37+
tabIndex={0}
38+
className="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-52"
39+
>
40+
<li className="border-b border-gray-200">{user && <div>{user.name || user.email}</div>}</li>
41+
<li>
42+
<a onClick={onSignout}>Logout</a>
43+
</li>
44+
</ul>
45+
</div>
46+
</div>
47+
</div>
48+
);
49+
}

0 commit comments

Comments
 (0)