Skip to content

Commit 84a608b

Browse files
committed
feat: add delete and ordering
1 parent f1b96a5 commit 84a608b

10 files changed

Lines changed: 2028 additions & 69 deletions

File tree

package-lock.json

Lines changed: 1587 additions & 52 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"react-toastify": "^9.1.1",
6262
"superjson": "1.12.2",
6363
"telegraf": "^4.11.2",
64+
"vitest": "^0.30.1",
6465
"zod": "^3.20.2",
6566
"zustand": "^4.3.2"
6667
},

src/app/dashboard/admin/projects/page.tsx

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
"use client";
2-
import { PencilIcon } from "@heroicons/react/24/outline";
2+
import { Dialog } from "@headlessui/react";
3+
import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
4+
import type { Project } from "@prisma/client";
35
import { reactApi } from "src/utils/trpc";
6+
import { useDialogStore } from "../../dialog.store";
7+
import { z } from "zod";
8+
import { ZodForm } from "src/projects/forms/zod-form";
9+
import { FORM_ERROR } from "final-form";
10+
import { TextField } from "src/projects/forms/text-field";
11+
import clsx from "clsx";
12+
import { SelectPicker } from "src/projects/forms/select-field";
13+
import { useState } from "react";
414

515
const ProjectsPage = () => {
16+
const [order, setOrder] = useState<string>("name");
617
const { data, isLoading, error } = reactApi.admin.getAllProjects.useQuery({
718
skip: 0,
819
take: 50,
20+
orderBy: order,
921
});
1022

1123
if (isLoading) {
@@ -27,6 +39,22 @@ const ProjectsPage = () => {
2739
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"></div>
2840
</div>
2941
</div>
42+
<div className="mt-10 flex items-center space-x-2">
43+
<span>Order By</span>
44+
<div className="w-80">
45+
<SelectPicker
46+
setValue={(v) => {
47+
setOrder(v);
48+
}}
49+
value={order}
50+
options={[
51+
{ label: "Name", value: "name" },
52+
{ label: "Created At", value: "created_at" },
53+
]}
54+
/>
55+
</div>
56+
</div>
57+
3058
<ProjectsList projects={data!} />
3159
</div>
3260
</>
@@ -54,6 +82,7 @@ const ProjectsList = ({ projects }: ProjectsListProps) => {
5482
utils.admin.invalidate();
5583
},
5684
});
85+
const deleteProject = useDeleteProject();
5786

5887
return (
5988
<div className="mt-8 flex flex-col">
@@ -106,17 +135,25 @@ const ProjectsList = ({ projects }: ProjectsListProps) => {
106135
<td className="relative space-x-2 whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
107136
<div className="flex content-center justify-end">
108137
{project.draft ? (
109-
<button
110-
className="btn-ghost btn-sm btn"
111-
onClick={() =>
112-
publishMut.mutate({
113-
projectId: project.id,
114-
draft: false,
115-
})
116-
}
117-
>
118-
publish
119-
</button>
138+
<>
139+
<button
140+
className="btn-ghost btn-sm btn"
141+
onClick={() =>
142+
publishMut.mutate({
143+
projectId: project.id,
144+
draft: false,
145+
})
146+
}
147+
>
148+
publish
149+
</button>
150+
<button
151+
className="btn-ghost btn-sm btn-circle btn"
152+
onClick={() => deleteProject(project)}
153+
>
154+
<TrashIcon className="w-4" />
155+
</button>
156+
</>
120157
) : (
121158
<button
122159
className="btn-ghost btn-sm btn"
@@ -162,3 +199,75 @@ const ProjectStatus = ({ draft }: { draft: boolean }) => {
162199
<span className={`${classes} bg-green-100 text-green-800`}>Published</span>
163200
);
164201
};
202+
203+
const useDeleteProject = () => {
204+
const createDialog = useDialogStore((s) => s.setDialog);
205+
return (project: Pick<Project, "id" | "name">) => {
206+
createDialog(<DeleteProjectDialog project={project} />);
207+
};
208+
};
209+
210+
function DeleteProjectDialog({
211+
project,
212+
}: {
213+
project: Pick<Project, "id" | "name">;
214+
}) {
215+
const close = useDialogStore((s) => s.closeDialog);
216+
const utils = reactApi.useContext();
217+
218+
const deleteProject = reactApi.admin.deleteProject.useMutation({
219+
onSuccess: async () => {
220+
await utils.admin.invalidate();
221+
close();
222+
},
223+
});
224+
225+
const DeleteSchema = z.object({
226+
check: z.literal("DELETE"),
227+
});
228+
229+
return (
230+
<>
231+
<Dialog.Title className="text-lg font-semibold text-gray-700">
232+
Delete Project
233+
</Dialog.Title>
234+
<Dialog.Description>
235+
Are you sure you want to delete <strong>{project.name}</strong>?
236+
</Dialog.Description>
237+
<div className="mt-10">
238+
<ZodForm
239+
schema={DeleteSchema}
240+
onSubmit={async () => {
241+
await deleteProject.mutateAsync({
242+
projectId: project.id,
243+
});
244+
}}
245+
>
246+
{({ handleSubmit, submitting, invalid, submitErrors }) => (
247+
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
248+
<TextField label={`Type \`DELETE\` to procede`} name="check" />
249+
<div>
250+
{submitErrors?.[FORM_ERROR] && (
251+
<span className="text-sm text-red-500">
252+
{submitErrors[FORM_ERROR]}
253+
</span>
254+
)}
255+
</div>
256+
<div className="flex justify-end">
257+
<button
258+
type="submit"
259+
disabled={invalid || submitting}
260+
className={clsx("btn-error btn-sm btn", {
261+
loading: submitting,
262+
})}
263+
>
264+
Delete
265+
</button>
266+
</div>
267+
</form>
268+
)}
269+
</ZodForm>
270+
</div>
271+
</>
272+
);
273+
}

src/pages/auth/signin.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export default function SignIn({ providers }: Props) {
5959
{error && <p className="mb-4 text-center">{errors[error]}</p>}
6060
<div className="mt-0">
6161
<div className="sm:mx-auto sm:w-full sm:max-w-md">
62-
<Logo className="mx-auto h-14 w-auto text-pink-600" />
62+
<Logo className="mx-auto h-14 w-auto text-green-600" />
6363
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-700">
6464
Space Hackability
6565
</h2>
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Listbox, Transition } from "@headlessui/react";
2+
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
3+
import clsx from "clsx";
4+
import { Fragment } from "react";
5+
import { Field } from "react-final-form";
6+
7+
interface SelectFieldProps<T = string> {
8+
name: string;
9+
label: string;
10+
options: PickerOption<T>[];
11+
makeChoiceText?: string;
12+
}
13+
14+
export function SelectField<T = any>({
15+
name,
16+
options,
17+
label,
18+
makeChoiceText = "Make a choice",
19+
}: SelectFieldProps<T>) {
20+
return (
21+
<div>
22+
<label htmlFor={name} className="block text-sm font-medium text-gray-700">
23+
{label}
24+
</label>
25+
<Field<T>
26+
name={name}
27+
render={({ input }) => (
28+
<SelectPicker<T>
29+
makeChoiceText={makeChoiceText}
30+
options={options}
31+
value={input.value}
32+
setValue={input.onChange}
33+
/>
34+
)}
35+
/>
36+
</div>
37+
);
38+
}
39+
40+
interface PickerOption<T = any> {
41+
value: T;
42+
label: string;
43+
}
44+
45+
interface SelectPickerProps<T = any> {
46+
setValue: (v: T) => void;
47+
value: T;
48+
options: PickerOption<T>[];
49+
makeChoiceText?: string;
50+
}
51+
52+
export const SelectPicker = <T,>({
53+
value,
54+
setValue,
55+
options,
56+
makeChoiceText,
57+
}: SelectPickerProps<T>) => {
58+
return (
59+
<Listbox value={value} onChange={setValue}>
60+
{({ open }) => (
61+
<div className="relative">
62+
<Listbox.Button className="relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 sm:text-sm">
63+
<span className="block truncate">
64+
{options.find((o) => o.value === value)?.label || makeChoiceText}
65+
</span>
66+
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
67+
<ChevronUpDownIcon
68+
className="h-5 w-5 text-gray-400"
69+
aria-hidden="true"
70+
/>
71+
</span>
72+
</Listbox.Button>
73+
74+
<Transition
75+
show={open}
76+
as={Fragment}
77+
leave="transition ease-in duration-100"
78+
leaveFrom="opacity-100"
79+
leaveTo="opacity-0"
80+
>
81+
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
82+
{options.map((v) => (
83+
<Listbox.Option
84+
key={v.label}
85+
className={({ active }) =>
86+
clsx(
87+
active ? "bg-green-600 text-white" : "text-gray-900",
88+
"relative cursor-default select-none py-2 pl-3 pr-9"
89+
)
90+
}
91+
value={v.value}
92+
>
93+
{({ selected, active }) => (
94+
<>
95+
<span
96+
className={clsx(
97+
selected ? "font-semibold" : "font-normal",
98+
"block truncate"
99+
)}
100+
>
101+
{v.label}
102+
</span>
103+
104+
{selected ? (
105+
<span
106+
className={clsx(
107+
active ? "text-white" : "text-green-600",
108+
"absolute inset-y-0 right-0 flex items-center pr-4"
109+
)}
110+
>
111+
<CheckIcon className="h-5 w-5" aria-hidden="true" />
112+
</span>
113+
) : null}
114+
</>
115+
)}
116+
</Listbox.Option>
117+
))}
118+
</Listbox.Options>
119+
</Transition>
120+
</div>
121+
)}
122+
</Listbox>
123+
);
124+
};

src/projects/forms/text-field.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import clsx from "clsx";
2+
import { Field } from "react-final-form";
3+
4+
interface TextFieldProps {
5+
name: string;
6+
label: string;
7+
max?: number;
8+
numRows?: number;
9+
type?: "text" | "password";
10+
size?: "normal" | "sm";
11+
}
12+
13+
export const TextField = ({
14+
label,
15+
name,
16+
max,
17+
numRows,
18+
type = "text",
19+
size = "normal",
20+
}: TextFieldProps) => (
21+
<Field
22+
type={type}
23+
name={name}
24+
id={name}
25+
render={({ input, meta }) => {
26+
return (
27+
<div>
28+
<label
29+
htmlFor="title"
30+
className="block text-sm font-medium text-gray-700"
31+
>
32+
{label}
33+
</label>
34+
<div className="mt-1 flex rounded-md shadow-sm">
35+
{numRows ? (
36+
<textarea
37+
{...input}
38+
rows={numRows}
39+
className="block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
40+
/>
41+
) : (
42+
<input
43+
className={clsx("input-primary input w-full", {
44+
"input-sm": size === "sm",
45+
})}
46+
{...input}
47+
/>
48+
)}
49+
</div>
50+
<div className="mt-1 flex h-5 justify-between text-xs ">
51+
<span className="text-sm text-red-400">
52+
{meta.touched && meta.error}
53+
</span>
54+
{max && (
55+
<span className={"text-stone-400"}>
56+
{input.value.length} / {max}
57+
</span>
58+
)}
59+
</div>
60+
</div>
61+
);
62+
}}
63+
/>
64+
);

0 commit comments

Comments
 (0)