add profile and remove older project
This commit is contained in:
parent
dc3c0bebbb
commit
121017dfb1
|
@ -1,4 +0,0 @@
|
||||||
# Update these with your Supabase details from your project settings > API
|
|
||||||
# https://app.supabase.com/project/_/settings/api
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
|
|
@ -1,41 +0,0 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
.env
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
|
@ -1,104 +0,0 @@
|
||||||
<a href="https://demo-nextjs-with-supabase.vercel.app/">
|
|
||||||
<img alt="Next.js and Supabase Starter Kit - the fastest way to build apps with Next.js and Supabase" src="https://demo-nextjs-with-supabase.vercel.app/opengraph-image.png">
|
|
||||||
<h1 align="center">Next.js and Supabase Starter Kit</h1>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
The fastest way to build apps with Next.js and Supabase
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="#features"><strong>Features</strong></a> ·
|
|
||||||
<a href="#demo"><strong>Demo</strong></a> ·
|
|
||||||
<a href="#deploy-to-vercel"><strong>Deploy to Vercel</strong></a> ·
|
|
||||||
<a href="#clone-and-run-locally"><strong>Clone and run locally</strong></a> ·
|
|
||||||
<a href="#feedback-and-issues"><strong>Feedback and issues</strong></a>
|
|
||||||
<a href="#more-supabase-examples"><strong>More Examples</strong></a>
|
|
||||||
</p>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Works across the entire [Next.js](https://nextjs.org) stack
|
|
||||||
- App Router
|
|
||||||
- Pages Router
|
|
||||||
- Middleware
|
|
||||||
- Client
|
|
||||||
- Server
|
|
||||||
- It just works!
|
|
||||||
- supabase-ssr. A package to configure Supabase Auth to use cookies
|
|
||||||
- Styling with [Tailwind CSS](https://tailwindcss.com)
|
|
||||||
- Components with [shadcn/ui](https://ui.shadcn.com/)
|
|
||||||
- Optional deployment with [Supabase Vercel Integration and Vercel deploy](#deploy-your-own)
|
|
||||||
- Environment variables automatically assigned to Vercel project
|
|
||||||
|
|
||||||
## Demo
|
|
||||||
|
|
||||||
You can view a fully working demo at [demo-nextjs-with-supabase.vercel.app](https://demo-nextjs-with-supabase.vercel.app/).
|
|
||||||
|
|
||||||
## Deploy to Vercel
|
|
||||||
|
|
||||||
Vercel deployment will guide you through creating a Supabase account and project.
|
|
||||||
|
|
||||||
After installation of the Supabase integration, all relevant environment variables will be assigned to the project so the deployment is fully functioning.
|
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&project-name=nextjs-with-supabase&repository-name=nextjs-with-supabase&demo-title=nextjs-with-supabase&demo-description=This+starter+configures+Supabase+Auth+to+use+cookies%2C+making+the+user%27s+session+available+throughout+the+entire+Next.js+app+-+Client+Components%2C+Server+Components%2C+Route+Handlers%2C+Server+Actions+and+Middleware.&demo-url=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2F&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&demo-image=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2Fopengraph-image.png)
|
|
||||||
|
|
||||||
The above will also clone the Starter kit to your GitHub, you can clone that locally and develop locally.
|
|
||||||
|
|
||||||
If you wish to just develop locally and not deploy to Vercel, [follow the steps below](#clone-and-run-locally).
|
|
||||||
|
|
||||||
## Clone and run locally
|
|
||||||
|
|
||||||
1. You'll first need a Supabase project which can be made [via the Supabase dashboard](https://database.new)
|
|
||||||
|
|
||||||
2. Create a Next.js app using the Supabase Starter template npx command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx create-next-app --example with-supabase with-supabase-app
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn create next-app --example with-supabase with-supabase-app
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm create next-app --example with-supabase with-supabase-app
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Use `cd` to change into the app's directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd with-supabase-app
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Rename `.env.example` to `.env.local` and update the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=[INSERT SUPABASE PROJECT URL]
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=[INSERT SUPABASE PROJECT API ANON KEY]
|
|
||||||
```
|
|
||||||
|
|
||||||
Both `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` can be found in [your Supabase project's API settings](https://app.supabase.com/project/_/settings/api)
|
|
||||||
|
|
||||||
5. You can now run the Next.js local development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The starter kit should now be running on [localhost:3000](http://localhost:3000/).
|
|
||||||
|
|
||||||
6. This template comes with the default shadcn/ui style initialized. If you instead want other ui.shadcn styles, delete `components.json` and [re-install shadcn/ui](https://ui.shadcn.com/docs/installation/next)
|
|
||||||
|
|
||||||
> Check out [the docs for Local Development](https://supabase.com/docs/guides/getting-started/local-development) to also run Supabase locally.
|
|
||||||
|
|
||||||
## Feedback and issues
|
|
||||||
|
|
||||||
Please file feedback and issues over on the [Supabase GitHub org](https://github.com/supabase/supabase/issues/new/choose).
|
|
||||||
|
|
||||||
## More Supabase examples
|
|
||||||
|
|
||||||
- [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments)
|
|
||||||
- [Cookie-based Auth and the Next.js 13 App Router (free course)](https://youtube.com/playlist?list=PL5S4mPUpp4OtMhpnp93EFSo42iQ40XjbF)
|
|
||||||
- [Supabase Auth and the Next.js App Router](https://github.com/supabase/supabase/tree/master/examples/auth/nextjs)
|
|
|
@ -1,40 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { encodedRedirect } from "@/utils/utils";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const forgotPasswordAction = async (formData: FormData) => {
|
|
||||||
const email = formData.get("email")?.toString();
|
|
||||||
const supabase = await createClient();
|
|
||||||
const origin = (await headers()).get("origin");
|
|
||||||
const callbackUrl = formData.get("callbackUrl")?.toString();
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
return encodedRedirect("error", "/forgot-password", "Email is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(error.message);
|
|
||||||
return encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/forgot-password",
|
|
||||||
"Could not reset password",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callbackUrl) {
|
|
||||||
return redirect(callbackUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return encodedRedirect(
|
|
||||||
"success",
|
|
||||||
"/forgot-password",
|
|
||||||
"Check your email for a link to reset your password.",
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,43 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { encodedRedirect } from "@/utils/utils";
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const resetPasswordAction = async (formData: FormData) => {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const password = formData.get("password") as string;
|
|
||||||
const confirmPassword = formData.get("confirmPassword") as string;
|
|
||||||
|
|
||||||
if (!password || !confirmPassword) {
|
|
||||||
encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/protected/reset-password",
|
|
||||||
"Password and confirm password are required"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/protected/reset-password",
|
|
||||||
"Passwords do not match"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.updateUser({
|
|
||||||
password: password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/protected/reset-password",
|
|
||||||
"Password update failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
encodedRedirect("success", "/protected/reset-password", "Password updated");
|
|
||||||
};
|
|
|
@ -1,100 +0,0 @@
|
||||||
// import { createClient } from "@/utils/supabase/server";
|
|
||||||
|
|
||||||
// export async function sendContactEmail(formData: {
|
|
||||||
// name: string;
|
|
||||||
// email: string;
|
|
||||||
// phone: string;
|
|
||||||
// typeMessage: string;
|
|
||||||
// message: string;
|
|
||||||
// }) {
|
|
||||||
// try {
|
|
||||||
// // Initialize Supabase
|
|
||||||
// const supabase = await createClient();
|
|
||||||
// const { resend } = useResend();
|
|
||||||
|
|
||||||
// // Get message type label
|
|
||||||
// const messageTypeLabel =
|
|
||||||
// typeMessageMap.get(formData.typeMessage) || "Unknown";
|
|
||||||
|
|
||||||
// // Save to Supabase
|
|
||||||
// const { data: contactData, error: contactError } = await supabase
|
|
||||||
// .from("contact_messages")
|
|
||||||
// .insert([
|
|
||||||
// {
|
|
||||||
// name: formData.name,
|
|
||||||
// email: formData.email,
|
|
||||||
// phone: formData.phone,
|
|
||||||
// message_type: formData.typeMessage,
|
|
||||||
// message_type_label: messageTypeLabel,
|
|
||||||
// message: formData.message,
|
|
||||||
// status: "new",
|
|
||||||
// },
|
|
||||||
// ])
|
|
||||||
// .select();
|
|
||||||
|
|
||||||
// if (contactError) {
|
|
||||||
// console.error("Error saving contact message to Supabase:", contactError);
|
|
||||||
// return {
|
|
||||||
// success: false,
|
|
||||||
// error: "Failed to save your message. Please try again later.",
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Render admin email template
|
|
||||||
// const adminEmailHtml = await render(
|
|
||||||
// AdminNotification({
|
|
||||||
// name: formData.name,
|
|
||||||
// email: formData.email,
|
|
||||||
// phone: formData.phone,
|
|
||||||
// messageType: messageTypeLabel,
|
|
||||||
// message: formData.message,
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// // Send email to admin
|
|
||||||
// const { data: emailData, error: emailError } = await resend.emails.send({
|
|
||||||
// from: "Contact Form <contact@backspacex.tech>",
|
|
||||||
// to: ["xdamazon17@gmail.com"],
|
|
||||||
// subject: `New Contact Form Submission: ${messageTypeLabel}`,
|
|
||||||
// html: adminEmailHtml,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (emailError) {
|
|
||||||
// console.error("Error sending email via Resend:", emailError);
|
|
||||||
// // Note: We don't return error here since the data is already saved to Supabase
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const userEmailHtml = await render(
|
|
||||||
// UserConfirmation({
|
|
||||||
// name: formData.name,
|
|
||||||
// messageType: messageTypeLabel,
|
|
||||||
// message: formData.message,
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// // Send confirmation email to user
|
|
||||||
// const { data: confirmationData, error: confirmationError } =
|
|
||||||
// await resend.emails.send({
|
|
||||||
// from: "Your Company <support@backspacex.tech>",
|
|
||||||
// to: [formData.email],
|
|
||||||
// subject: "Thank you for contacting us",
|
|
||||||
// html: userEmailHtml,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (confirmationError) {
|
|
||||||
// console.error("Error sending confirmation email:", confirmationError);
|
|
||||||
// // Note: We don't return error here either
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// success: true,
|
|
||||||
// message: "Your message has been sent successfully!",
|
|
||||||
// };
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error("Unexpected error in sendContactEmail:", error);
|
|
||||||
// return {
|
|
||||||
// success: false,
|
|
||||||
// error: "An unexpected error occurred. Please try again later.",
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// }
|
|
|
@ -1,37 +0,0 @@
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
|
|
||||||
export const checkSession = async () => {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
error,
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
session,
|
|
||||||
redirectTo: "/protected/dashboard", // or your preferred authenticated route
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "No active session",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "An unexpected error occurred",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,54 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { encodedRedirect } from "@/utils/utils";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { checkSession } from "./session";
|
|
||||||
|
|
||||||
export const signInAction = async (formData: FormData) => {
|
|
||||||
const supabase = await createClient();
|
|
||||||
const email = formData.get("email") as string;
|
|
||||||
const encodeEmail = encodeURIComponent(email);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First, check for existing session
|
|
||||||
const { session, error: sessionError } = await checkSession();
|
|
||||||
|
|
||||||
// If there's an active session and the email matches
|
|
||||||
if (session && session.user.email === email) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Already logged in",
|
|
||||||
redirectTo: "/protected/dashboard", // or wherever you want to redirect logged in users
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no active session or different email, proceed with OTP
|
|
||||||
const { data, error } = await supabase.auth.signInWithOtp({
|
|
||||||
email,
|
|
||||||
options: {
|
|
||||||
shouldCreateUser: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
redirectTo: `/verify-otp?email=${encodeEmail}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "OTP has been sent to your email",
|
|
||||||
redirectTo: `/verify-otp?email=${encodeEmail}`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "An unexpected error occurred",
|
|
||||||
redirectTo: "/sign-in",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,10 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const signOutAction = async () => {
|
|
||||||
const supabase = await createClient();
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
return redirect("/sign-in");
|
|
||||||
};
|
|
|
@ -1,39 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { encodedRedirect } from "@/utils/utils";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
export const signUpAction = async (formData: FormData) => {
|
|
||||||
const email = formData.get("email")?.toString();
|
|
||||||
const password = formData.get("password")?.toString();
|
|
||||||
const supabase = await createClient();
|
|
||||||
const origin = (await headers()).get("origin");
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return encodedRedirect(
|
|
||||||
"error",
|
|
||||||
"/sign-up",
|
|
||||||
"Email and password are required"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.signUp({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
options: {
|
|
||||||
emailRedirectTo: `${origin}/auth/callback`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(error.code + " " + error.message);
|
|
||||||
return encodedRedirect("error", "/sign-up", error.message);
|
|
||||||
} else {
|
|
||||||
return encodedRedirect(
|
|
||||||
"success",
|
|
||||||
"/sign-up",
|
|
||||||
"Thanks for signing up! Please check your email for a verification link."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,30 +0,0 @@
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const verifyOtpAction = async (formData: FormData) => {
|
|
||||||
const email = formData.get("email") as string;
|
|
||||||
const token = formData.get("token") as string;
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
console.log("email", email);
|
|
||||||
console.log("token", token);
|
|
||||||
|
|
||||||
if (!email || !token) {
|
|
||||||
redirect("/error?message=Email and OTP are required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
error,
|
|
||||||
} = await supabase.auth.verifyOtp({
|
|
||||||
email,
|
|
||||||
token,
|
|
||||||
type: "email",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return redirect(`/verify-otp?error=${encodeURIComponent(error.message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect("/protected/dashboard?message=OTP verified successfully");
|
|
||||||
};
|
|
|
@ -1,57 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { SignInFormData } from "@/src/models/auth/sign-in.model";
|
|
||||||
import { authRepository } from "@/src/repositories/authentication.repository";
|
|
||||||
|
|
||||||
export async function signIn(
|
|
||||||
data: SignInFormData
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
try {
|
|
||||||
await authRepository.signIn(data);
|
|
||||||
return { success: true, message: "Check your email for the login link!" };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Authentication error:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Authentication failed. Please try again.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signOut(): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
await authRepository.signOut();
|
|
||||||
return { success: true, message: "You have been signed out." };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Sign out error:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Sign out failed. Please try again.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUser() {
|
|
||||||
try {
|
|
||||||
const user = await authRepository.getUser();
|
|
||||||
return { success: true, user };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get user error:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to get user information.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { checkSession } from "./_actions/session";
|
|
||||||
|
|
||||||
export default async function Layout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const sessionResult = await checkSession();
|
|
||||||
|
|
||||||
// If there's an active session, redirect to dashboard
|
|
||||||
if (sessionResult.success && sessionResult.redirectTo) {
|
|
||||||
redirect(sessionResult.redirectTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="max-w-full gap-12 items-start">{children}</div>;
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
import { SignInForm } from "@/components/auth/signin-form";
|
|
||||||
import { Message } from "@/components/form-message";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { GalleryVerticalEnd, Globe } from "lucide-react";
|
|
||||||
|
|
||||||
export default async function Login(props: { searchParams: Promise<Message> }) {
|
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-svh lg:grid-cols-5">
|
|
||||||
<div className="flex flex-col gap-4 p-6 md:p-10 bg-[#171717] lg:col-span-2 relative border border-r-2 border-r-gray-400 border-opacity-20">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<a href="#" className="flex items-center gap-2 font-medium">
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
||||||
<GalleryVerticalEnd className="size-4" />
|
|
||||||
</div>
|
|
||||||
Sigap Tech.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<SignInForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative hidden bg-[#0a0a0a] lg:flex items-center justify-center lg:col-span-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="absolute top-6 right-6 text-white bg-[#242424] border-gray-700 hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<Globe className="mr-0 h-4 w-4" />
|
|
||||||
Showcase
|
|
||||||
</Button>
|
|
||||||
<div className="flex flex-col max-w-md">
|
|
||||||
<div className="text-6xl text-gray-600 mb-8">"</div>
|
|
||||||
<h2 className="text-4xl font-bold text-white mb-8">
|
|
||||||
@Sigap Tech. Is the best to manage your crime data and report.
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<img
|
|
||||||
src="https://github.com/shadcn.png"
|
|
||||||
alt="Profile"
|
|
||||||
className="w-12 h-12 rounded-full"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">@codewithbhargav</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { ArrowUpRight, InfoIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export function SmtpMessage() {
|
|
||||||
return (
|
|
||||||
<div className="bg-muted/50 px-5 py-3 border rounded-md flex gap-4">
|
|
||||||
<InfoIcon size={16} className="mt-0.5" />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<small className="text-sm text-secondary-foreground">
|
|
||||||
<strong> Note:</strong> Emails are rate limited. Enable Custom SMTP to
|
|
||||||
increase the rate limit.
|
|
||||||
</small>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href="https://supabase.com/docs/guides/auth/auth-smtp"
|
|
||||||
target="_blank"
|
|
||||||
className="text-primary/50 hover:text-primary flex items-center text-sm gap-1"
|
|
||||||
>
|
|
||||||
Learn more <ArrowUpRight size={14} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
// import { GalleryVerticalEnd } from "lucide-react";
|
|
||||||
|
|
||||||
// export default async function VerifyOtpPage() {
|
|
||||||
// return (
|
|
||||||
// <div className="grid min-h-svh">
|
|
||||||
// <div className="flex flex-col gap-4 p-6 md:p-10 relative">
|
|
||||||
// <div className="flex justify-between items-center">
|
|
||||||
// <a href="#" className="flex items-center gap-2 font-medium">
|
|
||||||
// <div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
||||||
// <GalleryVerticalEnd className="size-4" />
|
|
||||||
// </div>
|
|
||||||
// Sigap Tech.
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
// <div className="flex flex-1 items-center justify-center">
|
|
||||||
// <div className="w-full max-w-lg">
|
|
||||||
// <VerifyOtpForm />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
// The `/auth/callback` route is required for the server-side auth flow implemented
|
|
||||||
// by the SSR package. It exchanges an auth code for the user's session.
|
|
||||||
// https://supabase.com/docs/guides/auth/server-side/nextjs
|
|
||||||
const requestUrl = new URL(request.url);
|
|
||||||
const code = requestUrl.searchParams.get("code");
|
|
||||||
const origin = requestUrl.origin;
|
|
||||||
const redirectTo = requestUrl.searchParams.get("redirect_to")?.toString();
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
await supabase.auth.exchangeCodeForSession(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (redirectTo) {
|
|
||||||
return NextResponse.redirect(`${origin}${redirectTo}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL to redirect to after sign up process completes
|
|
||||||
return NextResponse.redirect(`${origin}/protected`);
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB |
|
@ -1,96 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 240 10% 3.9%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 240 10% 3.9%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 240 10% 3.9%;
|
|
||||||
--primary: 153 60% 53%; /* Supabase green */
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--secondary: 240 4.8% 95.9%;
|
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
|
||||||
--muted: 240 4.8% 95.9%;
|
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
|
||||||
--accent: 240 4.8% 95.9%;
|
|
||||||
--accent-foreground: 240 5.9% 10%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 240 5.9% 90%;
|
|
||||||
--input: 240 5.9% 90%;
|
|
||||||
--ring: 153 60% 53%; /* Matching primary */
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--chart-1: 153 60% 53%; /* Supabase green */
|
|
||||||
--chart-2: 183 65% 50%;
|
|
||||||
--chart-3: 213 70% 47%;
|
|
||||||
--chart-4: 243 75% 44%;
|
|
||||||
--chart-5: 273 80% 41%;
|
|
||||||
--sidebar-background: 0 0% 98%;
|
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
|
||||||
--sidebar-border: 220 13% 91%;
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 0 0% 9%; /* #171717 */
|
|
||||||
--foreground: 210 20% 98%;
|
|
||||||
--card: 0 0% 9%; /* #171717 */
|
|
||||||
--card-foreground: 210 20% 98%;
|
|
||||||
--popover: 0 0% 9%; /* #171717 */
|
|
||||||
--popover-foreground: 210 20% 98%;
|
|
||||||
--primary: 153 60% 53%; /* Supabase green */
|
|
||||||
--primary-foreground: 210 20% 98%;
|
|
||||||
--secondary: 220 8% 15%;
|
|
||||||
--secondary-foreground: 210 20% 98%;
|
|
||||||
--muted: 220 8% 15%;
|
|
||||||
--muted-foreground: 217 10% 64%;
|
|
||||||
--accent: 220 8% 15%;
|
|
||||||
--accent-foreground: 210 20% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 20% 98%;
|
|
||||||
--border: 220 8% 15%;
|
|
||||||
--input: 220 8% 15%;
|
|
||||||
--ring: 153 60% 53%; /* Matching primary */
|
|
||||||
--chart-1: 153 60% 53%; /* Supabase green */
|
|
||||||
--chart-2: 183 65% 50%;
|
|
||||||
--chart-3: 213 70% 47%;
|
|
||||||
--chart-4: 243 75% 44%;
|
|
||||||
--chart-5: 273 80% 41%;
|
|
||||||
--sidebar-background: 240 5.9% 10%;
|
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-primary: 224.3 76.3% 48%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,86 +0,0 @@
|
||||||
import DeployButton from "@/components/deploy-button";
|
|
||||||
import { EnvVarWarning } from "@/components/env-var-warning";
|
|
||||||
import HeaderAuth from "@/components/header-auth";
|
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
||||||
import { hasEnvVars } from "@/utils/supabase/check-env-vars";
|
|
||||||
import { Geist } from "next/font/google";
|
|
||||||
import { ThemeProvider } from "next-themes";
|
|
||||||
import Link from "next/link";
|
|
||||||
import "./globals.css";
|
|
||||||
import ReactQueryProvider from "@/providers/react-query-provider";
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
|
|
||||||
const defaultUrl = process.env.VERCEL_URL
|
|
||||||
? `https://${process.env.VERCEL_URL}`
|
|
||||||
: "http://localhost:3000";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
metadataBase: new URL(defaultUrl),
|
|
||||||
title: "Next.js and Supabase Starter Kit",
|
|
||||||
description: "The fastest way to build apps with Next.js and Supabase",
|
|
||||||
};
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
display: "swap",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en" className={geistSans.className} suppressHydrationWarning>
|
|
||||||
<body className="bg-background text-foreground">
|
|
||||||
<ThemeProvider
|
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<ReactQueryProvider>
|
|
||||||
<main className="min-h-screen flex flex-col items-center">
|
|
||||||
<div className="flex-1 w-full gap-20 items-center">
|
|
||||||
{/* <nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
|
|
||||||
<div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm">
|
|
||||||
<div className="flex gap-5 items-center font-semibold">
|
|
||||||
<Link href={"/"}>
|
|
||||||
<SigapLogo />
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DeployButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-5 items-center">
|
|
||||||
{!hasEnvVars ? <EnvVarWarning /> : <HeaderAuth />}
|
|
||||||
<ThemeSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav> */}
|
|
||||||
<div className="flex flex-col max-w-full p-0">
|
|
||||||
{children}
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto">
|
|
||||||
<p>
|
|
||||||
Powered by{" "}
|
|
||||||
<a
|
|
||||||
href=""
|
|
||||||
target="_blank"
|
|
||||||
className="font-bold hover:underline"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Politeknik Negeri Jember
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</footer> */}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</ReactQueryProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 283 KiB |
|
@ -1,16 +0,0 @@
|
||||||
import Hero from "@/components/hero";
|
|
||||||
import ConnectSupabaseSteps from "@/components/tutorial/connect-supabase-steps";
|
|
||||||
import SignUpUserSteps from "@/components/tutorial/sign-up-user-steps";
|
|
||||||
import { hasEnvVars } from "@/utils/supabase/check-env-vars";
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Hero />
|
|
||||||
<main className="flex-1 flex flex-col gap-6 px-4">
|
|
||||||
<h2 className="font-medium text-xl mb-4">Next steps</h2>
|
|
||||||
{hasEnvVars ? <SignUpUserSteps /> : <ConnectSupabaseSteps />}
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
import FetchDataSteps from "@/components/tutorial/fetch-data-steps";
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { InfoIcon } from "lucide-react";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function ProtectedPage() {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return redirect("/sign-in");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 w-full flex flex-col gap-12">
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="bg-accent text-sm p-3 px-5 rounded-md text-foreground flex gap-3 items-center">
|
|
||||||
<InfoIcon size="16" strokeWidth={2} />
|
|
||||||
This is a protected page that you can only see as an authenticated
|
|
||||||
user
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 items-start">
|
|
||||||
<h2 className="font-bold text-2xl mb-4">Your user details</h2>
|
|
||||||
<pre className="text-xs font-mono p-3 rounded border max-h-32 overflow-auto">
|
|
||||||
{JSON.stringify(user, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-bold text-2xl mb-4">Next steps</h2>
|
|
||||||
<FetchDataSteps />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 283 KiB |
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.ts",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,120 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type React from "react";
|
|
||||||
|
|
||||||
import { Lock } from "lucide-react";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { Input } from "../ui/input";
|
|
||||||
import { SubmitButton } from "../submit-button";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { FormField } from "../form-field";
|
|
||||||
import { useSignInForm } from "@/src/controller/auth/sign-in-controller";
|
|
||||||
|
|
||||||
export function SignInForm({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<"form">) {
|
|
||||||
const {
|
|
||||||
formData,
|
|
||||||
errors,
|
|
||||||
isSubmitting,
|
|
||||||
message,
|
|
||||||
handleChange,
|
|
||||||
handleSubmit,
|
|
||||||
} = useSignInForm();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex-1 flex items-center justify-center px-6">
|
|
||||||
<div className="w-full max-w-xl space-y-8">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white">
|
|
||||||
Welcome back
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-400">Sign in to your account</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<span className="block sm:inline">{message}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full bg-[#1C1C1C] text-white border-gray-800 hover:bg-[#2C2C2C] hover:border-gray-700"
|
|
||||||
size="lg"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<Lock className="mr-2 h-5 w-5" />
|
|
||||||
Continue with SSO
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-gray-800" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs">
|
|
||||||
<span className="bg-background px-2 text-gray-400">or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" {...props}>
|
|
||||||
<FormField
|
|
||||||
label="Email"
|
|
||||||
input={
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className={`bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600 ${
|
|
||||||
errors.email ? "border-red-500" : ""
|
|
||||||
}`}
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
error={errors.email}
|
|
||||||
/>
|
|
||||||
<SubmitButton
|
|
||||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
||||||
size="lg"
|
|
||||||
pendingText="Signing In..."
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</SubmitButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="text-center text-lg">
|
|
||||||
<span className="text-gray-400">Don't have an account? </span>
|
|
||||||
<Link
|
|
||||||
href="/contact-us"
|
|
||||||
className="text-white hover:text-emerald-500"
|
|
||||||
>
|
|
||||||
Contact Us
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-400">
|
|
||||||
By continuing, you agree to Sigap's{" "}
|
|
||||||
<a href="#" className="text-gray-400 hover:text-white">
|
|
||||||
Terms of Service
|
|
||||||
</a>{" "}
|
|
||||||
and{" "}
|
|
||||||
<a href="#" className="text-gray-400 hover:text-white">
|
|
||||||
Privacy Policy
|
|
||||||
</a>
|
|
||||||
, and to receive periodic emails with updates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,96 +0,0 @@
|
||||||
// "use client";
|
|
||||||
|
|
||||||
|
|
||||||
// const FormSchema = z.object({
|
|
||||||
// token: z.string().min(6, {
|
|
||||||
// message: "Your one-time password must be 6 characters.",
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
|
|
||||||
// interface InputOTPFormProps {
|
|
||||||
// className?: string;
|
|
||||||
// [key: string]: any;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export function VerifyOtpForm({ className, ...props }: InputOTPFormProps) {
|
|
||||||
// const searchParams = useSearchParams();
|
|
||||||
// const email = searchParams.get("email") || "";
|
|
||||||
|
|
||||||
// const form = useForm<z.infer<typeof FormSchema>>({
|
|
||||||
// resolver: zodResolver(FormSchema),
|
|
||||||
// defaultValues: {
|
|
||||||
// token: "",
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// async function onSubmit(data: z.infer<typeof FormSchema>) {
|
|
||||||
// try {
|
|
||||||
// } catch (error) {
|
|
||||||
// toast({
|
|
||||||
// variant: "destructive",
|
|
||||||
// title: "Error",
|
|
||||||
// description: "Failed to verify OTP. Please try again.",
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className={cn("flex flex-col gap-6", className)} {...props}>
|
|
||||||
// <Card className="bg-[#171717] border-gray-800 text-white border-none">
|
|
||||||
// <CardHeader className="text-center">
|
|
||||||
// <CardTitle className="text-2xl font-bold">
|
|
||||||
// One-Time Password
|
|
||||||
// </CardTitle>
|
|
||||||
// <CardDescription className="text-gray-400">
|
|
||||||
// One time password is a security feature that helps protect your data
|
|
||||||
// </CardDescription>
|
|
||||||
// </CardHeader>
|
|
||||||
// <CardContent>
|
|
||||||
// <Form {...form}>
|
|
||||||
// <form className="space-y-6">
|
|
||||||
// <input type="hidden" name="email" value={email} />
|
|
||||||
// <FormField
|
|
||||||
// control={form.control}
|
|
||||||
// name="token"
|
|
||||||
// render={({ field }) => (
|
|
||||||
// <FormItem>
|
|
||||||
// <FormControl>
|
|
||||||
// <InputOTP maxLength={6} {...field}>
|
|
||||||
// <InputOTPGroup className="flex w-full items-center justify-center space-x-2">
|
|
||||||
// {[...Array(6)].map((_, index) => (
|
|
||||||
// <InputOTPSlot
|
|
||||||
// key={index}
|
|
||||||
// index={index}
|
|
||||||
// className="w-12 h-12 text-xl border-2 border-gray-700 bg-[#1C1C1C] text-white rounded-md focus:border-emerald-600 focus:ring-emerald-600"
|
|
||||||
// />
|
|
||||||
// ))}
|
|
||||||
// </InputOTPGroup>
|
|
||||||
// </InputOTP>
|
|
||||||
// </FormControl>
|
|
||||||
// <FormDescription className="flex w-full justify-center items-center text-gray-400">
|
|
||||||
// Please enter the one-time password sent to {email}.
|
|
||||||
// </FormDescription>
|
|
||||||
// <FormMessage className="text-red-400" />
|
|
||||||
// </FormItem>
|
|
||||||
// )}
|
|
||||||
// />
|
|
||||||
// <div className="flex justify-center">
|
|
||||||
// <SubmitButton
|
|
||||||
// className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
||||||
// pendingText="Verifying..."
|
|
||||||
// formAction={verifyOtpAction}
|
|
||||||
// >
|
|
||||||
// Submit
|
|
||||||
// </SubmitButton>
|
|
||||||
// </div>
|
|
||||||
// </form>
|
|
||||||
// </Form>
|
|
||||||
// </CardContent>
|
|
||||||
// </Card>
|
|
||||||
// <div className="text-balance text-center text-xs text-gray-400 [&_a]:text-emerald-500 [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-emerald-400">
|
|
||||||
// By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
|
||||||
// and <a href="#">Privacy Policy</a>.
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
|
@ -1,23 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { type ComponentProps } from "react";
|
|
||||||
import { useFormStatus } from "react-dom";
|
|
||||||
|
|
||||||
type Props = ComponentProps<typeof Button> & {
|
|
||||||
pendingText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SubmitButton({
|
|
||||||
children,
|
|
||||||
pendingText = "Submitting...",
|
|
||||||
...props
|
|
||||||
}: Props) {
|
|
||||||
const { pending } = useFormStatus();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button type="submit" aria-disabled={pending} {...props}>
|
|
||||||
{pending ? pendingText : children}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,78 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Laptop, Moon, Sun } from "lucide-react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const ThemeSwitcher = () => {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
|
|
||||||
// useEffect only runs on the client, so now we can safely show the UI
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICON_SIZE = 16;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size={"sm"}>
|
|
||||||
{theme === "light" ? (
|
|
||||||
<Sun
|
|
||||||
key="light"
|
|
||||||
size={ICON_SIZE}
|
|
||||||
className={"text-muted-foreground"}
|
|
||||||
/>
|
|
||||||
) : theme === "dark" ? (
|
|
||||||
<Moon
|
|
||||||
key="dark"
|
|
||||||
size={ICON_SIZE}
|
|
||||||
className={"text-muted-foreground"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Laptop
|
|
||||||
key="system"
|
|
||||||
size={ICON_SIZE}
|
|
||||||
className={"text-muted-foreground"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-content" align="start">
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={theme}
|
|
||||||
onValueChange={(e) => setTheme(e)}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem className="flex gap-2" value="light">
|
|
||||||
<Sun size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
||||||
<span>Light</span>
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem className="flex gap-2" value="dark">
|
|
||||||
<Moon size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
||||||
<span>Dark</span>
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem className="flex gap-2" value="system">
|
|
||||||
<Laptop size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
||||||
<span>System</span>
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { ThemeSwitcher };
|
|
|
@ -1,56 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-10 px-4 py-2",
|
|
||||||
sm: "h-9 rounded-md px-3",
|
|
||||||
lg: "h-11 rounded-md px-8",
|
|
||||||
icon: "h-10 w-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Button.displayName = "Button";
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
|
@ -1,25 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface InputProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, type, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Input.displayName = "Input";
|
|
||||||
|
|
||||||
export { Input };
|
|
|
@ -1,26 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const labelVariants = cva(
|
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
||||||
);
|
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
|
||||||
VariantProps<typeof labelVariants>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(labelVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
Label.displayName = LabelPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
export { Label };
|
|
|
@ -1,194 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// Inspired by react-hot-toast library
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ToastActionElement,
|
|
||||||
ToastProps,
|
|
||||||
} from "@/components/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
|
||||||
id: string
|
|
||||||
title?: React.ReactNode
|
|
||||||
description?: React.ReactNode
|
|
||||||
action?: ToastActionElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionTypes = {
|
|
||||||
ADD_TOAST: "ADD_TOAST",
|
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
function genId() {
|
|
||||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
|
||||||
return count.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| {
|
|
||||||
type: ActionType["ADD_TOAST"]
|
|
||||||
toast: ToasterToast
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["UPDATE_TOAST"]
|
|
||||||
toast: Partial<ToasterToast>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["DISMISS_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["REMOVE_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
toasts: ToasterToast[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
|
||||||
if (toastTimeouts.has(toastId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
toastTimeouts.delete(toastId)
|
|
||||||
dispatch({
|
|
||||||
type: "REMOVE_TOAST",
|
|
||||||
toastId: toastId,
|
|
||||||
})
|
|
||||||
}, TOAST_REMOVE_DELAY)
|
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
|
||||||
switch (action.type) {
|
|
||||||
case "ADD_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
|
||||||
const { toastId } = action
|
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|
||||||
// but I'll keep it here for simplicity
|
|
||||||
if (toastId) {
|
|
||||||
addToRemoveQueue(toastId)
|
|
||||||
} else {
|
|
||||||
state.toasts.forEach((toast) => {
|
|
||||||
addToRemoveQueue(toast.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === toastId || toastId === undefined
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "REMOVE_TOAST":
|
|
||||||
if (action.toastId === undefined) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] }
|
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
|
||||||
memoryState = reducer(memoryState, action)
|
|
||||||
listeners.forEach((listener) => {
|
|
||||||
listener(memoryState)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
|
||||||
const id = genId()
|
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_TOAST",
|
|
||||||
toast: { ...props, id },
|
|
||||||
})
|
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "ADD_TOAST",
|
|
||||||
toast: {
|
|
||||||
...props,
|
|
||||||
id,
|
|
||||||
open: true,
|
|
||||||
onOpenChange: (open) => {
|
|
||||||
if (!open) dismiss()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
dismiss,
|
|
||||||
update,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useToast() {
|
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
listeners.push(setState)
|
|
||||||
return () => {
|
|
||||||
const index = listeners.indexOf(setState)
|
|
||||||
if (index > -1) {
|
|
||||||
listeners.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toast,
|
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useToast, toast }
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { type NextRequest } from "next/server";
|
|
||||||
import { updateSession } from "@/utils/supabase/middleware";
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
|
||||||
return await updateSession(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
/*
|
|
||||||
* Match all request paths except:
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
|
|
||||||
* Feel free to modify this pattern to include more paths.
|
|
||||||
*/
|
|
||||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
||||||
],
|
|
||||||
};
|
|
|
@ -1,7 +0,0 @@
|
||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,45 +0,0 @@
|
||||||
{
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"db:seed": "npx prisma db seed"
|
|
||||||
},
|
|
||||||
"prisma": {
|
|
||||||
"seed": "ts-node prisma/seed.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/client": "^6.4.1",
|
|
||||||
"@radix-ui/react-checkbox": "^1.1.1",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
|
||||||
"@supabase/ssr": "latest",
|
|
||||||
"@supabase/supabase-js": "latest",
|
|
||||||
"@tabler/icons-react": "^3.30.0",
|
|
||||||
"@tanstack/react-query": "^5.66.9",
|
|
||||||
"autoprefixer": "10.4.20",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.468.0",
|
|
||||||
"next": "latest",
|
|
||||||
"next-themes": "^0.4.4",
|
|
||||||
"prettier": "^3.3.3",
|
|
||||||
"react": "19.0.0",
|
|
||||||
"react-dom": "19.0.0",
|
|
||||||
"sonner": "^2.0.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.10.2",
|
|
||||||
"@types/react": "^19.0.2",
|
|
||||||
"@types/react-dom": "19.0.2",
|
|
||||||
"postcss": "8.4.49",
|
|
||||||
"prisma": "^6.4.1",
|
|
||||||
"tailwind-merge": "^2.5.2",
|
|
||||||
"tailwindcss": "3.4.17",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.7.2"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,230 +0,0 @@
|
||||||
|
|
||||||
const crimeCategories = [
|
|
||||||
{
|
|
||||||
name: "TERHADAP KETERTIBAN UMUM",
|
|
||||||
description:
|
|
||||||
"Tindak pidana yang mengganggu ketertiban dan kenyamanan masyarakat secara umum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MEMBAHAYAKAN KAM UMUM",
|
|
||||||
description:
|
|
||||||
"Kejahatan yang berpotensi membahayakan keamanan dan keselamatan masyarakat.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PEMBAKARAN",
|
|
||||||
description:
|
|
||||||
"Tindakan membakar properti atau bangunan secara sengaja yang dapat membahayakan orang lain.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "KEBAKARAN / MELETUS",
|
|
||||||
description:
|
|
||||||
"Kejadian kebakaran atau ledakan yang disebabkan oleh kelalaian atau tindakan kriminal.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MEMBER SUAP",
|
|
||||||
description:
|
|
||||||
"Pemberian sesuatu kepada pejabat untuk mempengaruhi keputusan atau tindakan tertentu.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SUMPAH PALSU",
|
|
||||||
description:
|
|
||||||
"Memberikan keterangan palsu di bawah sumpah, biasanya dalam proses hukum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PEMALSUAN MATERAI",
|
|
||||||
description:
|
|
||||||
"Tindakan memalsukan materai resmi dengan tujuan merugikan pihak lain.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PEMALSUAN SURAT",
|
|
||||||
description:
|
|
||||||
"Membuat, mengubah, atau memalsukan surat yang memiliki kekuatan hukum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERZINAHAN",
|
|
||||||
description:
|
|
||||||
"Hubungan seksual di luar pernikahan yang melanggar norma hukum dan sosial.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERKOSAAN",
|
|
||||||
description: "Tindak pemaksaan hubungan seksual tanpa persetujuan korban.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERJUDIAN",
|
|
||||||
description:
|
|
||||||
"Segala bentuk kegiatan taruhan atau perjudian yang melanggar hukum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENGHINAAN",
|
|
||||||
description: "Ucapan atau tindakan yang merendahkan martabat seseorang.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENCULIKAN",
|
|
||||||
description:
|
|
||||||
"Mengambil atau menahan seseorang secara paksa dengan tujuan tertentu.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERBUATAN TIDAK MENYENANGKAN",
|
|
||||||
description:
|
|
||||||
"Tindakan yang mengakibatkan ketidaknyamanan atau kerugian emosional pada orang lain.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PEMBUNUHAN",
|
|
||||||
description: "Menghilangkan nyawa seseorang secara sengaja.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENGANIAYAAN RINGAN",
|
|
||||||
description:
|
|
||||||
"Tindakan kekerasan yang mengakibatkan luka ringan pada korban.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENGANIAYAAN BERAT",
|
|
||||||
description: "Kekerasan yang mengakibatkan luka serius atau kematian.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "KELALAIAN AKIBATKAN ORANG MATI",
|
|
||||||
description: "Kelalaian yang menyebabkan kematian seseorang.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "KELALAIAN AKIBATKAN ORANG LUKA",
|
|
||||||
description: "Kelalaian yang mengakibatkan luka pada orang lain.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENCURIAN BIASA",
|
|
||||||
description: "Mengambil barang milik orang lain tanpa izin.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "CURAT",
|
|
||||||
description:
|
|
||||||
"Pencurian dengan pemberatan, seperti pembobolan rumah atau kendaraan.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "CURINGAN",
|
|
||||||
description: "Pencurian ringan dengan nilai kerugian yang kecil.",
|
|
||||||
},
|
|
||||||
{ name: "CURAS", description: "Pencurian dengan kekerasan terhadap korban." },
|
|
||||||
{ name: "CURANMOR", description: "Pencurian kendaraan bermotor." },
|
|
||||||
{
|
|
||||||
name: "PENGEROYOKAN",
|
|
||||||
description:
|
|
||||||
"Penyerangan secara bersama-sama terhadap satu atau beberapa orang.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PREMANISME",
|
|
||||||
description:
|
|
||||||
"Tindakan kekerasan, pemerasan, atau ancaman oleh kelompok tertentu untuk menguasai wilayah.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PEMERASAN DAN PENGANCAMAN",
|
|
||||||
description:
|
|
||||||
"Tindakan meminta sesuatu dengan ancaman kekerasan atau pengungkapan informasi merugikan.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENGGELAPAN",
|
|
||||||
description:
|
|
||||||
"Mengambil barang atau uang yang dipercayakan untuk kepentingan pribadi.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENIPUAN",
|
|
||||||
description:
|
|
||||||
"Menipu orang lain dengan tujuan mendapatkan keuntungan secara melawan hukum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENGRUSAKAN",
|
|
||||||
description: "Merusak properti orang lain secara sengaja.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "KENAKALAN REMAJA",
|
|
||||||
description:
|
|
||||||
"Perilaku menyimpang oleh remaja yang dapat meresahkan masyarakat.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MENERIMA SUAP",
|
|
||||||
description:
|
|
||||||
"Menerima sesuatu dengan imbalan pengaruh keputusan atau tindakan tertentu.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENADAHAN",
|
|
||||||
description: "Menyimpan atau menjual barang hasil kejahatan.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PEKERJAKAN ANAK",
|
|
||||||
description:
|
|
||||||
"Mempekerjakan anak di bawah umur dalam pekerjaan yang melanggar hukum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "AGRARIA",
|
|
||||||
description: "Kejahatan terkait sengketa tanah dan sumber daya agraria.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERADILAN ANAK",
|
|
||||||
description: "Tindak pidana yang melibatkan anak dalam proses peradilan.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERLINDUNGAN ANAK",
|
|
||||||
description:
|
|
||||||
"Kejahatan yang melanggar hak-hak anak dan kesejahteraan mereka.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PKDRT",
|
|
||||||
description:
|
|
||||||
"Tindak kekerasan dalam rumah tangga yang merugikan anggota keluarga.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERLINDUNGAN TKI",
|
|
||||||
description:
|
|
||||||
"Kejahatan yang melibatkan pelanggaran terhadap hak Tenaga Kerja Indonesia di luar negeri.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PERLINDUNGAN SAKSI – KORBAN",
|
|
||||||
description:
|
|
||||||
"Tindakan yang mengancam keselamatan saksi atau korban dalam proses hukum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PTPPO",
|
|
||||||
description:
|
|
||||||
"Perdagangan orang, termasuk eksploitasi tenaga kerja dan seksual.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PORNOGRAFI",
|
|
||||||
description:
|
|
||||||
"Produksi, distribusi, atau konsumsi materi pornografi yang melanggar hukum.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SISTEM PERADILAN ANAK",
|
|
||||||
description:
|
|
||||||
"Pelaksanaan hukum dan keadilan yang berkaitan dengan anak sebagai pelaku kejahatan.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PENYELENGGARAN PEMILU",
|
|
||||||
description:
|
|
||||||
"Kejahatan yang mengganggu proses pemilihan umum, seperti kecurangan suara.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PEMERINTAH DAERAH",
|
|
||||||
description:
|
|
||||||
"Tindak pidana yang dilakukan oleh atau melibatkan aparat pemerintah daerah.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "KEIMIGRASIAN",
|
|
||||||
description:
|
|
||||||
"Pelanggaran hukum yang terkait dengan masuk dan keluarnya orang dari suatu negara.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "EKSTRADISI",
|
|
||||||
description:
|
|
||||||
"Proses penyerahan tersangka atau terpidana ke negara lain untuk diadili.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "LAHGUN SENPI/HANDAK/SAJAM",
|
|
||||||
description:
|
|
||||||
"Penyalahgunaan senjata api, bahan peledak, atau senjata tajam.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PIDUM LAINNYA",
|
|
||||||
description:
|
|
||||||
"Tindak pidana umum lainnya yang tidak tercakup dalam kategori di atas.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default crimeCategories;
|
|
|
@ -1,294 +0,0 @@
|
||||||
import {
|
|
||||||
IconHome,
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconSettings,
|
|
||||||
IconMap,
|
|
||||||
IconDatabase,
|
|
||||||
IconUsers,
|
|
||||||
IconMessageCircle,
|
|
||||||
IconMenu2,
|
|
||||||
IconAlbum,
|
|
||||||
IconMusicBolt,
|
|
||||||
IconCommand,
|
|
||||||
IconFrame,
|
|
||||||
IconChartPie,
|
|
||||||
IconRobot,
|
|
||||||
IconSearch,
|
|
||||||
IconDashboard,
|
|
||||||
IconRobotFace,
|
|
||||||
IconGavel,
|
|
||||||
IconMapPin2,
|
|
||||||
IconSlice,
|
|
||||||
IconWorldBolt,
|
|
||||||
IconWorld,
|
|
||||||
IconPin,
|
|
||||||
IconMapPin,
|
|
||||||
IconLayersDifference,
|
|
||||||
IconFriends,
|
|
||||||
IconDna,
|
|
||||||
IconDna2,
|
|
||||||
IconUsersGroup,
|
|
||||||
IconNavigation,
|
|
||||||
IconApps,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export const navData = {
|
|
||||||
user: {
|
|
||||||
name: "user",
|
|
||||||
email: "m@example.com",
|
|
||||||
avatar: "/avatars/shadcn.jpg",
|
|
||||||
},
|
|
||||||
teams: [
|
|
||||||
{
|
|
||||||
name: "Acme Inc",
|
|
||||||
icon: IconAlbum,
|
|
||||||
plan: "Enterprise",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Acme Corp.",
|
|
||||||
icon: IconMusicBolt,
|
|
||||||
plan: "Startup",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Evil Corp.",
|
|
||||||
icon: IconCommand,
|
|
||||||
plan: "Free",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
NavPreMain: [
|
|
||||||
{
|
|
||||||
title: "Welcome",
|
|
||||||
url: "/protected/welcome",
|
|
||||||
icon: IconHome,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Search",
|
|
||||||
url: "/search",
|
|
||||||
icon: IconSearch,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Sigap AI",
|
|
||||||
url: "/protected/sigap-ai",
|
|
||||||
icon: IconRobotFace,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: "Dashboard",
|
|
||||||
url: "/protected/dashboard",
|
|
||||||
slug: "dashboard",
|
|
||||||
orderSeq: 1,
|
|
||||||
icon: IconApps,
|
|
||||||
isActive: true,
|
|
||||||
subItems: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Crime Management",
|
|
||||||
url: "/crime-management",
|
|
||||||
slug: "crime-management",
|
|
||||||
orderSeq: 2,
|
|
||||||
icon: IconGavel,
|
|
||||||
isActive: true,
|
|
||||||
subItems: [
|
|
||||||
{
|
|
||||||
title: "Crime Overview",
|
|
||||||
url: "/protected/crime-management/crime-overview",
|
|
||||||
slug: "crime-overview",
|
|
||||||
icon: IconMapPin2,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Crime Categories",
|
|
||||||
url: "/crime-management/crime-categories",
|
|
||||||
slug: "crime-categories",
|
|
||||||
icon: IconSlice,
|
|
||||||
orderSeq: 2,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Cases",
|
|
||||||
url: "/crime-management/crime-cases",
|
|
||||||
slug: "crime-cases",
|
|
||||||
icon: IconAlertTriangle,
|
|
||||||
orderSeq: 3,
|
|
||||||
isActive: true,
|
|
||||||
subSubItems: [
|
|
||||||
{
|
|
||||||
title: "New Case",
|
|
||||||
url: "/crime-management/crime-cases/case-new",
|
|
||||||
slug: "new-case",
|
|
||||||
icon: IconAlertTriangle,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Active Cases",
|
|
||||||
url: "/crime-management/crime-cases/case-active",
|
|
||||||
slug: "active-cases",
|
|
||||||
icon: IconAlertTriangle,
|
|
||||||
orderSeq: 2,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Resolved Cases",
|
|
||||||
url: "/crime-management/crime-cases/case-closed",
|
|
||||||
slug: "resolved-cases",
|
|
||||||
icon: IconAlertTriangle,
|
|
||||||
orderSeq: 3,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Geographic Data",
|
|
||||||
url: "/geographic-data",
|
|
||||||
slug: "geographic-data",
|
|
||||||
orderSeq: 3,
|
|
||||||
icon: IconWorld,
|
|
||||||
isActive: true,
|
|
||||||
subItems: [
|
|
||||||
{
|
|
||||||
title: "Locations",
|
|
||||||
url: "/geographic-data/locations",
|
|
||||||
slug: "locations",
|
|
||||||
icon: IconMapPin,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
subSubItems: [
|
|
||||||
{
|
|
||||||
title: "Cities",
|
|
||||||
url: "/geographic-data/cities",
|
|
||||||
slug: "cities",
|
|
||||||
icon: IconMap,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Districts",
|
|
||||||
url: "/geographic-data/districts",
|
|
||||||
slug: "districts",
|
|
||||||
icon: IconMap,
|
|
||||||
orderSeq: 2,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Geographic Info",
|
|
||||||
url: "/geographic-data/geographic-info",
|
|
||||||
slug: "geographic-info",
|
|
||||||
icon: IconLayersDifference,
|
|
||||||
orderSeq: 3,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Demographics",
|
|
||||||
url: "/demographics",
|
|
||||||
slug: "demographics",
|
|
||||||
orderSeq: 4,
|
|
||||||
icon: IconFriends,
|
|
||||||
isActive: true,
|
|
||||||
subItems: [
|
|
||||||
{
|
|
||||||
title: "Demographics Data",
|
|
||||||
url: "/demographics/demographics-data",
|
|
||||||
slug: "demographics-data",
|
|
||||||
icon: IconDna2,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "User Management",
|
|
||||||
url: "/user-management",
|
|
||||||
slug: "user-management",
|
|
||||||
orderSeq: 5,
|
|
||||||
icon: IconUsers,
|
|
||||||
isActive: true,
|
|
||||||
subItems: [
|
|
||||||
{
|
|
||||||
title: "Users",
|
|
||||||
url: "/protected/user-management/users",
|
|
||||||
slug: "users",
|
|
||||||
icon: IconUsersGroup,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// title: "Communication",
|
|
||||||
// url: "/communication",
|
|
||||||
// slug: "communication",
|
|
||||||
// orderSeq: 6,
|
|
||||||
// icon: IconMessageCircle,
|
|
||||||
// isActive: true,
|
|
||||||
// subItems: [
|
|
||||||
// {
|
|
||||||
// title: "Contact Messages",
|
|
||||||
// url: "/communication/contact-messages",
|
|
||||||
// slug: "contact-messages",
|
|
||||||
// icon: IconMessageCircle,
|
|
||||||
// orderSeq: 1,
|
|
||||||
// isActive: true,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
url: "/settings",
|
|
||||||
slug: "settings",
|
|
||||||
orderSeq: 6,
|
|
||||||
icon: IconSettings,
|
|
||||||
isActive: true,
|
|
||||||
subItems: [
|
|
||||||
{
|
|
||||||
title: "Navigation",
|
|
||||||
url: "/settings/navigation",
|
|
||||||
slug: "navigation",
|
|
||||||
icon: IconNavigation,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
subSubItems: [
|
|
||||||
{
|
|
||||||
title: "Nav Items",
|
|
||||||
url: "/settings/navigation/nav-items",
|
|
||||||
slug: "nav-items",
|
|
||||||
icon: IconMenu2,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
subSubItems: [
|
|
||||||
{
|
|
||||||
title: "Nav Sub Items",
|
|
||||||
url: "/settings/navigation/nav-sub-items",
|
|
||||||
slug: "nav-sub-items",
|
|
||||||
icon: IconMenu2,
|
|
||||||
orderSeq: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
reports: [
|
|
||||||
{
|
|
||||||
name: "Crime Reports",
|
|
||||||
url: "#",
|
|
||||||
icon: IconFrame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Demographics Reports",
|
|
||||||
url: "#",
|
|
||||||
icon: IconChartPie,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
|
@ -1,204 +0,0 @@
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
previewFeatures = ["postgresqlExtensions"]
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
directUrl = env("DIRECT_URL")
|
|
||||||
extensions = [pgcrypto, uuid_ossp(map: "uuid-ossp", schema: "extensions")]
|
|
||||||
}
|
|
||||||
|
|
||||||
model cities {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
geographic_id String? @db.Uuid
|
|
||||||
name String @db.VarChar(100)
|
|
||||||
code String @db.VarChar(10)
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
geographics geographics? @relation(fields: [geographic_id], references: [id])
|
|
||||||
crimes crimes[]
|
|
||||||
demographics demographics[]
|
|
||||||
districts districts[]
|
|
||||||
|
|
||||||
@@index([name])
|
|
||||||
}
|
|
||||||
|
|
||||||
model contact_messages {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
name String? @db.VarChar(255)
|
|
||||||
email String? @db.VarChar(255)
|
|
||||||
phone String? @db.VarChar(20)
|
|
||||||
message_type String? @db.VarChar(50)
|
|
||||||
message_type_label String? @db.VarChar(50)
|
|
||||||
message String?
|
|
||||||
status status_contact_messages @default(new)
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @db.Timestamptz(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
model crime_cases {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
crime_id String? @db.Uuid
|
|
||||||
crime_category_id String? @db.Uuid
|
|
||||||
date DateTime @db.Timestamptz(6)
|
|
||||||
time DateTime @db.Timestamptz(6)
|
|
||||||
location String @db.VarChar(255)
|
|
||||||
latitude Float
|
|
||||||
longitude Float
|
|
||||||
description String
|
|
||||||
victim_count Int
|
|
||||||
status crime_status @default(new)
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
crime_categories crime_categories? @relation(fields: [crime_category_id], references: [id])
|
|
||||||
crimes crimes? @relation(fields: [crime_id], references: [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
model crime_categories {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
name String @db.VarChar(255)
|
|
||||||
description String
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
crime_cases crime_cases[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model crimes {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
district_id String? @db.Uuid
|
|
||||||
city_id String? @db.Uuid
|
|
||||||
year Int
|
|
||||||
number_of_crime Int
|
|
||||||
rate crime_rates @default(low)
|
|
||||||
heat_map Json?
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
crime_cases crime_cases[]
|
|
||||||
cities cities? @relation(fields: [city_id], references: [id])
|
|
||||||
districts districts? @relation(fields: [district_id], references: [id])
|
|
||||||
|
|
||||||
@@unique([city_id, year])
|
|
||||||
@@unique([district_id, year])
|
|
||||||
}
|
|
||||||
|
|
||||||
model demographics {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
district_id String? @db.Uuid
|
|
||||||
city_id String? @db.Uuid
|
|
||||||
province_id String? @db.Uuid
|
|
||||||
year Int
|
|
||||||
population Int
|
|
||||||
population_density Float
|
|
||||||
poverty_rate Float
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
cities cities? @relation(fields: [city_id], references: [id])
|
|
||||||
districts districts? @relation(fields: [district_id], references: [id])
|
|
||||||
|
|
||||||
@@unique([city_id, year])
|
|
||||||
@@unique([district_id, year])
|
|
||||||
}
|
|
||||||
|
|
||||||
model districts {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
city_id String @db.Uuid
|
|
||||||
name String @db.VarChar(100)
|
|
||||||
code String @db.VarChar(10)
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
crimes crimes[]
|
|
||||||
demographics demographics[]
|
|
||||||
cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade)
|
|
||||||
geographics geographics?
|
|
||||||
|
|
||||||
@@index([name])
|
|
||||||
}
|
|
||||||
|
|
||||||
model geographics {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
district_id String? @unique @db.Uuid
|
|
||||||
latitude Float?
|
|
||||||
longitude Float?
|
|
||||||
land_area Float?
|
|
||||||
polygon Json?
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
|
||||||
cities cities[]
|
|
||||||
districts districts? @relation(fields: [district_id], references: [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
model profiles {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
user_id String @unique @db.Uuid
|
|
||||||
bio String?
|
|
||||||
address String? @db.VarChar(255)
|
|
||||||
city String? @db.VarChar(100)
|
|
||||||
country String? @db.VarChar(100)
|
|
||||||
birth_date DateTime?
|
|
||||||
users users @relation(fields: [user_id], references: [id])
|
|
||||||
|
|
||||||
@@index([user_id])
|
|
||||||
}
|
|
||||||
|
|
||||||
model users {
|
|
||||||
id String @id @db.Uuid
|
|
||||||
email String @unique @db.VarChar(255)
|
|
||||||
email_verified Boolean @default(false)
|
|
||||||
first_name String? @db.VarChar(255)
|
|
||||||
last_name String? @db.VarChar(255)
|
|
||||||
avatar String? @db.VarChar(255)
|
|
||||||
role roles @default(user)
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime
|
|
||||||
banned_until DateTime?
|
|
||||||
confirmation_sent_at DateTime?
|
|
||||||
confirmation_token String? @db.VarChar(255)
|
|
||||||
deleted_at DateTime?
|
|
||||||
email_change String? @db.VarChar(255)
|
|
||||||
email_change_sent_at DateTime?
|
|
||||||
email_change_token String? @db.VarChar(255)
|
|
||||||
email_confirmed_at DateTime?
|
|
||||||
encrypted_password String? @db.VarChar(255)
|
|
||||||
is_anonymous Boolean? @default(false)
|
|
||||||
is_sso_user Boolean? @default(false)
|
|
||||||
last_sign_in_at DateTime?
|
|
||||||
phone String? @db.VarChar(20)
|
|
||||||
phone_confirmed_at DateTime?
|
|
||||||
raw_app_meta_data Json?
|
|
||||||
raw_user_meta_data Json?
|
|
||||||
reauthentication_sent_at DateTime?
|
|
||||||
reauthentication_token String? @db.VarChar(255)
|
|
||||||
recovery_sent_at DateTime?
|
|
||||||
recovery_token String? @db.VarChar(255)
|
|
||||||
providers Json? @default("[]")
|
|
||||||
profiles profiles?
|
|
||||||
|
|
||||||
@@index([role])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum crime_rates {
|
|
||||||
low
|
|
||||||
medium
|
|
||||||
high
|
|
||||||
}
|
|
||||||
|
|
||||||
enum crime_status {
|
|
||||||
new
|
|
||||||
in_progress
|
|
||||||
resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
enum roles {
|
|
||||||
admin
|
|
||||||
staff
|
|
||||||
user
|
|
||||||
}
|
|
||||||
|
|
||||||
enum status_contact_messages {
|
|
||||||
new
|
|
||||||
read
|
|
||||||
replied
|
|
||||||
resolved
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const ReactQueryProvider = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const [queryClient] = useState(() => new QueryClient());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ReactQueryProvider;
|
|
|
@ -1,85 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { signIn } from "@/app/(auth-pages)/action";
|
|
||||||
import {
|
|
||||||
defaultSignInValues,
|
|
||||||
SignInFormData,
|
|
||||||
signInSchema,
|
|
||||||
} from "@/src/models/auth/sign-in.model";
|
|
||||||
import { useState, type FormEvent, type ChangeEvent } from "react";
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
type SignInFormErrors = Partial<Record<keyof SignInFormData, string>>;
|
|
||||||
|
|
||||||
export function useSignInForm() {
|
|
||||||
const [formData, setFormData] = useState<SignInFormData>(defaultSignInValues);
|
|
||||||
const [errors, setErrors] = useState<SignInFormErrors>({});
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
try {
|
|
||||||
signInSchema.parse(formData);
|
|
||||||
setErrors({});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
const formattedErrors: SignInFormErrors = {};
|
|
||||||
error.errors.forEach((err) => {
|
|
||||||
const path = err.path[0] as keyof SignInFormData;
|
|
||||||
formattedErrors[path] = err.message;
|
|
||||||
});
|
|
||||||
setErrors(formattedErrors);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signIn(formData);
|
|
||||||
if (result.success) {
|
|
||||||
setMessage(result.message);
|
|
||||||
} else {
|
|
||||||
setErrors({
|
|
||||||
email: result.message || "Sign in failed. Please try again.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Sign in failed", error);
|
|
||||||
setErrors({
|
|
||||||
email: "An unexpected error occurred. Please try again.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
formData,
|
|
||||||
errors,
|
|
||||||
isSubmitting,
|
|
||||||
message,
|
|
||||||
setFormData,
|
|
||||||
handleChange,
|
|
||||||
handleSubmit,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// Define the sign-in form schema using Zod
|
|
||||||
export const signInSchema = z.object({
|
|
||||||
email: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: "Email is required" })
|
|
||||||
.email({ message: "Invalid email address" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export the type derived from the schema
|
|
||||||
export type SignInFormData = z.infer<typeof signInSchema>;
|
|
||||||
|
|
||||||
// Default values for the form
|
|
||||||
export const defaultSignInValues: SignInFormData = {
|
|
||||||
email: "",
|
|
||||||
};
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// Define the verify OTP form schema using Zod
|
|
||||||
export const verifyOtpSchema = z.object({
|
|
||||||
email: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: "Email is required" })
|
|
||||||
.email({ message: "Invalid email address" }),
|
|
||||||
token: z.string().min(6, { message: "OTP is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export the type derived from the schema
|
|
||||||
export type VerifyOtpFormData = z.infer<typeof verifyOtpSchema>;
|
|
|
@ -1,39 +0,0 @@
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { SignInFormData } from "../models/auth/sign-in.model";
|
|
||||||
|
|
||||||
export class AuthRepository {
|
|
||||||
async signIn({ email }: SignInFormData) {
|
|
||||||
const supabase = await createClient();
|
|
||||||
const { data, error } = await supabase.auth.signInWithOtp({
|
|
||||||
email,
|
|
||||||
options: {
|
|
||||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async signOut() {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.signOut();
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUser() {
|
|
||||||
const supabase = await createClient();
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authRepository = new AuthRepository();
|
|
|
@ -1,80 +0,0 @@
|
||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: [
|
|
||||||
"./pages/**/*.{ts,tsx}",
|
|
||||||
"./components/**/*.{ts,tsx}",
|
|
||||||
"./app/**/*.{ts,tsx}",
|
|
||||||
"./src/**/*.{ts,tsx}",
|
|
||||||
],
|
|
||||||
prefix: "",
|
|
||||||
theme: {
|
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: "2rem",
|
|
||||||
screens: {
|
|
||||||
"2xl": "1400px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: "hsl(var(--border))",
|
|
||||||
input: "hsl(var(--input))",
|
|
||||||
ring: "hsl(var(--ring))",
|
|
||||||
background: "hsl(var(--background))",
|
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "hsl(var(--primary))",
|
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: "hsl(var(--popover))",
|
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
DEFAULT: "hsl(var(--card))",
|
|
||||||
foreground: "hsl(var(--card-foreground))",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: "var(--radius)",
|
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
"accordion-down": {
|
|
||||||
from: { height: "0" },
|
|
||||||
to: { height: "var(--radix-accordion-content-height)" },
|
|
||||||
},
|
|
||||||
"accordion-up": {
|
|
||||||
from: { height: "var(--radix-accordion-content-height)" },
|
|
||||||
to: { height: "0" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require("tailwindcss-animate")],
|
|
||||||
} satisfies Config;
|
|
||||||
|
|
||||||
export default config;
|
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
// This check can be removed
|
|
||||||
// it is just for tutorial purposes
|
|
||||||
|
|
||||||
export const hasEnvVars =
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL &&
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { createBrowserClient } from "@supabase/ssr";
|
|
||||||
|
|
||||||
export const createClient = () =>
|
|
||||||
createBrowserClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
);
|
|
|
@ -1,62 +0,0 @@
|
||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export const updateSession = async (request: NextRequest) => {
|
|
||||||
// This `try/catch` block is only here for the interactive tutorial.
|
|
||||||
// Feel free to remove once you have Supabase connected.
|
|
||||||
try {
|
|
||||||
// Create an unmodified response
|
|
||||||
let response = NextResponse.next({
|
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const supabase = createServerClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return request.cookies.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
cookiesToSet.forEach(({ name, value }) =>
|
|
||||||
request.cookies.set(name, value),
|
|
||||||
);
|
|
||||||
response = NextResponse.next({
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
response.cookies.set(name, value, options),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// This will refresh session if expired - required for Server Components
|
|
||||||
// https://supabase.com/docs/guides/auth/server-side/nextjs
|
|
||||||
const user = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
// protected routes
|
|
||||||
if (request.nextUrl.pathname.startsWith("/protected") && user.error) {
|
|
||||||
return NextResponse.redirect(new URL("/sign-in", request.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.nextUrl.pathname === "/" && !user.error) {
|
|
||||||
return NextResponse.redirect(new URL("/protected", request.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (e) {
|
|
||||||
// If you are here, a Supabase client could not be created!
|
|
||||||
// This is likely because you have not set up environment variables.
|
|
||||||
// Check out http://localhost:3000 for Next Steps.
|
|
||||||
return NextResponse.next({
|
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,29 +0,0 @@
|
||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const createClient = async () => {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
return createServerClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
try {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
cookieStore.set(name, value, options);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// The `set` method was called from a Server Component.
|
|
||||||
// This can be ignored if you have middleware refreshing
|
|
||||||
// user sessions.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,16 +0,0 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirects to a specified path with an encoded message as a query parameter.
|
|
||||||
* @param {('error' | 'success')} type - The type of message, either 'error' or 'success'.
|
|
||||||
* @param {string} path - The path to redirect to.
|
|
||||||
* @param {string} message - The message to be encoded and added as a query parameter.
|
|
||||||
* @returns {never} This function doesn't return as it triggers a redirect.
|
|
||||||
*/
|
|
||||||
export function encodedRedirect(
|
|
||||||
type: "error" | "success",
|
|
||||||
path: string,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
return redirect(`${path}?${type}=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"files.autoSave": "off"
|
||||||
|
}
|
|
@ -71,33 +71,3 @@ export async function signOut() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get current user
|
|
||||||
export async function getCurrentUser(): Promise<User> {
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
error,
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching current user:", error);
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userDetail = await db.users.findUnique({
|
|
||||||
where: {
|
|
||||||
id: user?.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
profile: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userDetail) {
|
|
||||||
throw new Error("User not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return userDetail;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SignInForm } from "@/components/auth/signin-form";
|
import { SignInForm } from "@/app/_components/auth/signin-form";
|
||||||
import { Message } from "@/components/form-message";
|
import { Message } from "@/app/_components/form-message";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { GalleryVerticalEnd, Globe } from "lucide-react";
|
import { GalleryVerticalEnd, Globe } from "lucide-react";
|
||||||
|
|
||||||
export default async function Login(props: { searchParams: Promise<Message> }) {
|
export default async function Login(props: { searchParams: Promise<Message> }) {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
|
import { VerifyOtpForm } from "@/app/_components/auth/verify-otp-form";
|
||||||
import { VerifyOtpForm } from "@/components/auth/verify-otp-form";
|
|
||||||
import { GalleryVerticalEnd } from "lucide-react";
|
import { GalleryVerticalEnd } from "lucide-react";
|
||||||
|
|
||||||
export default async function VerifyOtpPage() {
|
export default async function VerifyOtpPage() {
|
||||||
|
@ -16,7 +15,7 @@ export default async function VerifyOtpPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<div className="w-full max-w-lg">
|
<div className="w-full max-w-lg">
|
||||||
<VerifyOtpForm />
|
<VerifyOtpForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="flex h-12 shrink-0 items-center justify-end border-b px-4 mb-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-8"></header>
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||||
|
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||||
|
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||||
|
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||||
|
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -6,11 +6,10 @@ import {
|
||||||
InviteUserParams,
|
InviteUserParams,
|
||||||
UpdateUserParams,
|
UpdateUserParams,
|
||||||
User,
|
User,
|
||||||
UserFromSupabase,
|
UserResponse,
|
||||||
} from "@/src/models/users/users.model";
|
} from "@/src/models/users/users.model";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { createAdminClient } from "@/utils/supabase/admin";
|
||||||
import { createClient as supabaseUser } from "@/utils/supabase/server";
|
|
||||||
|
|
||||||
// Initialize Supabase client with admin key
|
// Initialize Supabase client with admin key
|
||||||
|
|
||||||
|
@ -41,8 +40,8 @@ export async function fetchUsers(): Promise<User[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// get current user
|
// get current user
|
||||||
export async function getCurrentUser(): Promise<User> {
|
export async function getCurrentUser(): Promise<UserResponse> {
|
||||||
const supabase = await supabaseUser();
|
const supabase = await createClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
|
@ -67,17 +66,19 @@ export async function getCurrentUser(): Promise<User> {
|
||||||
throw new Error("User not found");
|
throw new Error("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return userDetail;
|
return {
|
||||||
|
data: {
|
||||||
|
user: userDetail,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new user
|
// Create a new user
|
||||||
export async function createUser(
|
export async function createUser(
|
||||||
params: CreateUserParams
|
params: CreateUserParams
|
||||||
): Promise<UserFromSupabase> {
|
): Promise<UserResponse> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.admin.createUser({
|
const { data, error } = await supabase.auth.admin.createUser({
|
||||||
email: params.email,
|
email: params.email,
|
||||||
|
@ -92,7 +93,10 @@ export async function createUser(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data.user,
|
data: {
|
||||||
|
user: data.user,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,11 +104,8 @@ export async function createUser(
|
||||||
export async function updateUser(
|
export async function updateUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
params: UpdateUserParams
|
params: UpdateUserParams
|
||||||
): Promise<UserFromSupabase> {
|
): Promise<UserResponse> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
|
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
|
||||||
email: params.email,
|
email: params.email,
|
||||||
|
@ -118,16 +119,16 @@ export async function updateUser(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data.user,
|
data: {
|
||||||
|
user: data.user,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete a user
|
// Delete a user
|
||||||
export async function deleteUser(userId: string): Promise<void> {
|
export async function deleteUser(userId: string): Promise<void> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.admin.deleteUser(userId);
|
const { error } = await supabase.auth.admin.deleteUser(userId);
|
||||||
|
|
||||||
|
@ -139,10 +140,7 @@ export async function deleteUser(userId: string): Promise<void> {
|
||||||
|
|
||||||
// Send password recovery email
|
// Send password recovery email
|
||||||
export async function sendPasswordRecovery(email: string): Promise<void> {
|
export async function sendPasswordRecovery(email: string): Promise<void> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
|
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
|
||||||
|
@ -156,10 +154,7 @@ export async function sendPasswordRecovery(email: string): Promise<void> {
|
||||||
|
|
||||||
// Send magic link
|
// Send magic link
|
||||||
export async function sendMagicLink(email: string): Promise<void> {
|
export async function sendMagicLink(email: string): Promise<void> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.signInWithOtp({
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
email,
|
email,
|
||||||
|
@ -175,11 +170,8 @@ export async function sendMagicLink(email: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ban a user
|
// Ban a user
|
||||||
export async function banUser(userId: string): Promise<UserFromSupabase> {
|
export async function banUser(userId: string): Promise<UserResponse> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ban for 100 years (effectively permanent)
|
// Ban for 100 years (effectively permanent)
|
||||||
const banUntil = new Date();
|
const banUntil = new Date();
|
||||||
|
@ -195,16 +187,16 @@ export async function banUser(userId: string): Promise<UserFromSupabase> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data.user,
|
data: {
|
||||||
|
user: data.user,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unban a user
|
// Unban a user
|
||||||
export async function unbanUser(userId: string): Promise<UserFromSupabase> {
|
export async function unbanUser(userId: string): Promise<UserResponse> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
|
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
|
||||||
ban_duration: "none",
|
ban_duration: "none",
|
||||||
|
@ -216,16 +208,16 @@ export async function unbanUser(userId: string): Promise<UserFromSupabase> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data.user,
|
data: {
|
||||||
|
user: data.user,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invite a user
|
// Invite a user
|
||||||
export async function inviteUser(params: InviteUserParams): Promise<void> {
|
export async function inviteUser(params: InviteUserParams): Promise<void> {
|
||||||
const supabase = createClient(
|
const supabase = createAdminClient();
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SERVICE_ROLE_SECRET!
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.admin.inviteUserByEmail(params.email, {
|
const { error } = await supabase.auth.admin.inviteUserByEmail(params.email, {
|
||||||
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
|
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
|
|
@ -1,6 +1,5 @@
|
||||||
import UserManagement from "@/components/admin/users/user-management";
|
import UserManagement from "@/app/_components/admin/users/user-management";
|
||||||
import { UserStats } from "@/components/admin/users/user-stats";
|
import { UserStats } from "@/app/_components/admin/users/user-stats";
|
||||||
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
return (
|
return (
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/app/_components/ui/breadcrumb";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/app/_components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
|
import { MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThemeSwitcher } from "@/app/_components/theme-switcher";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
import { InboxDrawer } from "@/app/_components/inbox-drawer";
|
||||||
|
import FloatingActionSearchBar from "@/app/_components/floating-action-search-bar";
|
||||||
|
import { AppSidebar } from "@/app/_components/admin/app-sidebar";
|
||||||
|
|
||||||
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppSidebar />
|
||||||
|
<SidebarInset>
|
||||||
|
{/* Navigation bar with SidebarTrigger and Breadcrumbs */}
|
||||||
|
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="flex h-16 shrink-0 items-center justify-end border-b px-4 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<SidebarTrigger className="" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="#">Sigap - v</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>Map</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<InboxDrawer showTitle={true} showAvatar={false} />
|
||||||
|
<ThemeSwitcher showTitle={true} />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-5 w-5" />
|
||||||
|
<span className="sr-only">More options</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Help</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>About</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header with other controls */}
|
||||||
|
<FloatingActionSearchBar />
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import FetchDataSteps from "@/components/tutorial/fetch-data-steps";
|
import FetchDataSteps from "@/app/_components/tutorial/fetch-data-steps";
|
||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon } from "lucide-react";
|
||||||
|
@ -19,7 +19,6 @@ export default async function ProtectedPage() {
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -39,7 +38,6 @@ export default async function ProtectedPage() {
|
||||||
<pre className="text-xs font-mono p-3 rounded border overflow-auto">
|
<pre className="text-xs font-mono p-3 rounded border overflow-auto">
|
||||||
{JSON.stringify(user, null, 2)}
|
{JSON.stringify(user, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-bold text-2xl mb-4">Next steps</h2>
|
<h2 className="font-bold text-2xl mb-4">Next steps</h2>
|
|
@ -6,7 +6,7 @@ import React, {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
|
@ -17,7 +17,7 @@ import {
|
||||||
PlaneTakeoff,
|
PlaneTakeoff,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import useDebounce from "@/hooks/use-debounce";
|
import useDebounce from "@/app/_hooks/use-debounce";
|
||||||
|
|
||||||
interface Action {
|
interface Action {
|
||||||
id: string;
|
id: string;
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { NavMain } from "@/components/admin/navigations/nav-main";
|
import { NavMain } from "@/app/_components/admin/navigations/nav-main";
|
||||||
import { NavReports } from "@/components/admin/navigations/nav-report";
|
import { NavReports } from "@/app/_components/admin/navigations/nav-report";
|
||||||
import { NavUser } from "@/components/admin/navigations/nav-user";
|
import { NavUser } from "@/app/_components/admin/navigations/nav-user";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
@ -12,14 +12,13 @@ import {
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/app/_components/ui/sidebar";
|
||||||
import { NavPreMain } from "./navigations/nav-pre-main";
|
import { NavPreMain } from "./navigations/nav-pre-main";
|
||||||
import { navData } from "@/prisma/data/nav";
|
import { navData } from "@/prisma/data/nav";
|
||||||
import { TeamSwitcher } from "../team-switcher";
|
import { TeamSwitcher } from "../team-switcher";
|
||||||
|
|
||||||
import { Profile, User } from "@/src/models/users/users.model";
|
import { Profile, User } from "@/src/models/users/users.model";
|
||||||
import { getCurrentUser } from "@/app/protected/(admin)/dashboard/user-management/action";
|
import { getCurrentUser } from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
||||||
|
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const [user, setUser] = React.useState<User | null>(null);
|
const [user, setUser] = React.useState<User | null>(null);
|
||||||
|
@ -30,7 +29,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const userData = await getCurrentUser();
|
const userData = await getCurrentUser();
|
||||||
setUser(userData);
|
setUser(userData.data.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch user:", error);
|
console.error("Failed to fetch user:", error);
|
||||||
} finally {
|
} finally {
|
|
@ -6,7 +6,7 @@ import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/app/_components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
|
@ -14,11 +14,11 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/app/_components/ui/sidebar";
|
||||||
|
|
||||||
import * as TablerIcons from "@tabler/icons-react";
|
import * as TablerIcons from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useNavigations } from "@/hooks/use-navigations";
|
import { useNavigations } from "@/app/_hooks/use-navigations";
|
||||||
|
|
||||||
interface SubSubItem {
|
interface SubSubItem {
|
||||||
title: string;
|
title: string;
|
|
@ -8,8 +8,8 @@ import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/app/_components/ui/sidebar";
|
||||||
import { useNavigations } from "@/hooks/use-navigations";
|
import { useNavigations } from "@/app/_hooks/use-navigations";
|
||||||
import { Search, Bot, Home } from "lucide-react";
|
import { Search, Bot, Home } from "lucide-react";
|
||||||
import { IconHome, IconRobot, IconSearch } from "@tabler/icons-react";
|
import { IconHome, IconRobot, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
|
@ -21,7 +21,7 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/app/_components/ui/sidebar";
|
||||||
|
|
||||||
import * as TablerIcons from "@tabler/icons-react";
|
import * as TablerIcons from "@tabler/icons-react";
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { ChevronsUpDown } from "lucide-react";
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/app/_components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
@ -11,24 +16,21 @@ import {
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/app/_components/ui/sidebar";
|
||||||
import {
|
import { IconLogout, IconSettings, IconSparkles } from "@tabler/icons-react";
|
||||||
IconBadgeCc,
|
import type { User } from "@/src/models/users/users.model";
|
||||||
IconBell,
|
import { signOut } from "@/app/(auth-pages)/action";
|
||||||
IconCreditCard,
|
import { SettingsDialog } from "../settings/setting-dialog";
|
||||||
IconLogout,
|
|
||||||
IconSparkles,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { Profile, User } from "@/src/models/users/users.model";
|
|
||||||
|
|
||||||
export function NavUser({ user }: { user: User | null }) {
|
export function NavUser({ user }: { user: User | null }) {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Use profile data with fallbacks
|
// Use profile data with fallbacks
|
||||||
const firstName = user?.profile?.first_name || "";
|
const firstName = user?.profile?.first_name || "";
|
||||||
|
@ -54,6 +56,12 @@ export function NavUser({ user }: { user: User | null }) {
|
||||||
return "U";
|
return "U";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle dialog close after successful profile update
|
||||||
|
const handleProfileUpdateSuccess = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
// You might want to refresh the user data here
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
|
@ -101,28 +109,30 @@ export function NavUser({ user }: { user: User | null }) {
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem className="space-x-2">
|
<DropdownMenuItem className="space-x-2">
|
||||||
<IconSparkles />
|
<IconSparkles className="size-4" />
|
||||||
<span>Upgrade to Pro</span>
|
<span>Upgrade to Pro</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem className="space-x-2">
|
<SettingsDialog
|
||||||
<IconBadgeCc />
|
user={user}
|
||||||
<span>Account</span>
|
trigger={
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem className="space-x-2">
|
className="space-x-2"
|
||||||
<IconCreditCard />
|
onSelect={(e) => {
|
||||||
<span>Billing</span>
|
e.preventDefault();
|
||||||
</DropdownMenuItem>
|
}}
|
||||||
<DropdownMenuItem className="space-x-2">
|
>
|
||||||
<IconBell />
|
<IconSettings className="size-4" />
|
||||||
<span>Notifications</span>
|
<span>Settings</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="space-x-2">
|
<DropdownMenuItem onSubmit={signOut} className="space-x-2">
|
||||||
<IconLogout />
|
<IconLogout className="size-4" />
|
||||||
<span>Log out</span>
|
<span>Log out</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
|
@ -0,0 +1,281 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import type { User } from "@/src/models/users/users.model";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/app/_components/ui/form";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/app/_components/ui/avatar";
|
||||||
|
import { Input } from "@/app/_components/ui/input";
|
||||||
|
import { Textarea } from "@/app/_components/ui/textarea";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import { Label } from "@/app/_components/ui/label";
|
||||||
|
import { ImageIcon, Loader2 } from "lucide-react";
|
||||||
|
import { createClient } from "@/utils/supabase/client";
|
||||||
|
|
||||||
|
// Profile update form schema
|
||||||
|
const profileFormSchema = z.object({
|
||||||
|
first_name: z.string().nullable().optional(),
|
||||||
|
last_name: z.string().nullable().optional(),
|
||||||
|
bio: z.string().nullable().optional(),
|
||||||
|
avatar: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProfileFormValues = z.infer<typeof profileFormSchema>;
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
user: User | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(
|
||||||
|
user?.profile?.avatar || null
|
||||||
|
);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
// Use profile data with fallbacks
|
||||||
|
const firstName = user?.profile?.first_name || "";
|
||||||
|
const lastName = user?.profile?.last_name || "";
|
||||||
|
const userEmail = user?.email || "";
|
||||||
|
const userBio = user?.profile?.bio || "";
|
||||||
|
|
||||||
|
const getFullName = () => {
|
||||||
|
return `${firstName} ${lastName}`.trim() || "User";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate initials for avatar fallback
|
||||||
|
const getInitials = () => {
|
||||||
|
if (firstName && lastName) {
|
||||||
|
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
if (firstName) {
|
||||||
|
return firstName[0].toUpperCase();
|
||||||
|
}
|
||||||
|
if (userEmail) {
|
||||||
|
return userEmail[0].toUpperCase();
|
||||||
|
}
|
||||||
|
return "U";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup form with react-hook-form and zod validation
|
||||||
|
const form = useForm<ProfileFormValues>({
|
||||||
|
resolver: zodResolver(profileFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
first_name: firstName || "",
|
||||||
|
last_name: lastName || "",
|
||||||
|
bio: userBio || "",
|
||||||
|
avatar: user?.profile?.avatar || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle avatar file upload
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !user?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Create a preview of the selected image
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
setAvatarPreview(objectUrl);
|
||||||
|
|
||||||
|
// Upload to Supabase Storage
|
||||||
|
const fileExt = file.name.split(".").pop();
|
||||||
|
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||||
|
const filePath = `avatars/${fileName}`;
|
||||||
|
|
||||||
|
const { error: uploadError, data } = await supabase.storage
|
||||||
|
.from("profiles")
|
||||||
|
.upload(filePath, file, {
|
||||||
|
upsert: true,
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
throw uploadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the public URL
|
||||||
|
const {
|
||||||
|
data: { publicUrl },
|
||||||
|
} = supabase.storage.from("profiles").getPublicUrl(filePath);
|
||||||
|
|
||||||
|
// Update the form value
|
||||||
|
form.setValue("avatar", publicUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading avatar:", error);
|
||||||
|
// Revert to previous avatar if upload fails
|
||||||
|
setAvatarPreview(user?.profile?.avatar || null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger file input click
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
|
try {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
// Update profile in database
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
first_name: data.first_name,
|
||||||
|
last_name: data.last_name,
|
||||||
|
bio: data.bio,
|
||||||
|
avatar: data.avatar,
|
||||||
|
})
|
||||||
|
.eq("user_id", user.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Call success callback
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating profile:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Avatar upload section at the top */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer group"
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
>
|
||||||
|
<Avatar className="h-24 w-24 border-2 border-border">
|
||||||
|
{avatarPreview ? (
|
||||||
|
<AvatarImage src={avatarPreview} alt={getFullName()} />
|
||||||
|
) : (
|
||||||
|
<AvatarFallback className="text-2xl">
|
||||||
|
{getInitials()}
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-6 w-6 text-white animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="h-6 w-6 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="avatar-upload"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Click avatar to upload a new image
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="first_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>First Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="last_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Tell us about yourself"
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Brief description for your profile.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading || form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save changes"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,284 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import type { User } from "@/src/models/users/users.model";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Loader2, ImageIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/app/_components/ui/form";
|
||||||
|
import { Input } from "@/app/_components/ui/input";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/app/_components/ui/avatar";
|
||||||
|
import { Label } from "@/app/_components/ui/label";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
import { Switch } from "@/app/_components/ui/switch";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { createClient } from "@/utils/supabase/client";
|
||||||
|
import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||||
|
|
||||||
|
const profileFormSchema = z.object({
|
||||||
|
preferred_name: z.string().nullable().optional(),
|
||||||
|
avatar: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProfileFormValues = z.infer<typeof profileFormSchema>;
|
||||||
|
|
||||||
|
interface ProfileSettingsProps {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
// Use profile data with fallbacks
|
||||||
|
const preferredName = user?.profile?.first_name || "";
|
||||||
|
const userEmail = user?.email || "";
|
||||||
|
const userAvatar = user?.profile?.avatar || "";
|
||||||
|
|
||||||
|
const form = useForm<ProfileFormValues>({
|
||||||
|
resolver: zodResolver(profileFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
preferred_name: preferredName || "",
|
||||||
|
avatar: userAvatar || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !user?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
// Upload to Supabase Storage
|
||||||
|
const fileExt = file.name.split(".").pop();
|
||||||
|
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||||
|
const filePath = `avatars/${fileName}`;
|
||||||
|
|
||||||
|
const { error: uploadError, data } = await supabase.storage
|
||||||
|
.from("profiles")
|
||||||
|
.upload(filePath, file, {
|
||||||
|
upsert: true,
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) throw uploadError;
|
||||||
|
|
||||||
|
// Get the public URL
|
||||||
|
const {
|
||||||
|
data: { publicUrl },
|
||||||
|
} = supabase.storage.from("profiles").getPublicUrl(filePath);
|
||||||
|
|
||||||
|
// Update the form value
|
||||||
|
form.setValue("avatar", publicUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading avatar:", error);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
|
try {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
// Update profile in database
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
first_name: data.preferred_name,
|
||||||
|
avatar: data.avatar,
|
||||||
|
})
|
||||||
|
.eq("user_id", user.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating profile:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[calc(100vh-140px)] w-full ">
|
||||||
|
<div className="space-y-16 px-20 py-10">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Account</h3>
|
||||||
|
<Separator className="" />
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer group"
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
>
|
||||||
|
<Avatar className="h-16 w-16">
|
||||||
|
<AvatarImage
|
||||||
|
src={form.watch("avatar") || ""}
|
||||||
|
alt={preferredName}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{preferredName?.[0]?.toUpperCase() ||
|
||||||
|
userEmail?.[0]?.toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="h-5 w-5 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Avatar>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Label>Preferred name</Label>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="preferred_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={userEmail.split("@")[0]}
|
||||||
|
className="bg-muted/50 w-80"
|
||||||
|
{...field}
|
||||||
|
value={field.value || userEmail.split("@")[0]}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
disabled={isUploading || form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save changes"
|
||||||
|
)}
|
||||||
|
</Button> */}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
|
<h3 className="text-base font-medium">Account security</h3>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{userEmail}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Change email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Password</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Set a permanent password to login to your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Change password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>2-step verification</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add an additional layer of security to your account during
|
||||||
|
login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Add verification method
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Passkeys</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Securely sign-in with on-device biometric authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Add passkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium">Support</h3>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Support access</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Grant temporary access to your account for support purposes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-destructive">Delete account</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Permanently delete the account and remove access from all
|
||||||
|
workspaces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="text-destructive">
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { User } from "@/src/models/users/users.model";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
|
||||||
|
interface SecuritySettingsProps {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecuritySettings({ user }: SecuritySettingsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Account Security</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage your account security settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">Email</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">Change email</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">Password</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Set a permanent password to login to your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">Change password</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">Two-step verification</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add an additional layer of security to your account during login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">Add verification method</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">Passkeys</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Securely sign-in with on-device biometric authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">Add passkey</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,191 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/app/_components/ui/dialog";
|
||||||
|
import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/app/_components/ui/avatar";
|
||||||
|
import {
|
||||||
|
IconBell,
|
||||||
|
IconFingerprint,
|
||||||
|
IconLock,
|
||||||
|
IconPlugConnected,
|
||||||
|
IconSettings,
|
||||||
|
IconUser,
|
||||||
|
IconUsers,
|
||||||
|
IconWorld,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import type { User } from "@/src/models/users/users.model";
|
||||||
|
import { ProfileSettings } from "./profile-settings";
|
||||||
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
user: User | null;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
defaultTab?: string;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsTab {
|
||||||
|
id: string;
|
||||||
|
icon: typeof IconUser;
|
||||||
|
title: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsSection {
|
||||||
|
title: string;
|
||||||
|
tabs: SettingsTab[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDialog({
|
||||||
|
user,
|
||||||
|
trigger,
|
||||||
|
defaultTab = "account",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: SettingsDialogProps) {
|
||||||
|
const [selectedTab, setSelectedTab] = React.useState(defaultTab);
|
||||||
|
|
||||||
|
// Get user display name
|
||||||
|
const preferredName = user?.profile?.first_name || "";
|
||||||
|
const userEmail = user?.email || "";
|
||||||
|
const displayName = preferredName || userEmail?.split("@")[0] || "User";
|
||||||
|
const userAvatar = user?.profile?.avatar || "";
|
||||||
|
|
||||||
|
const sections: SettingsSection[] = [
|
||||||
|
{
|
||||||
|
title: "Account",
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "account",
|
||||||
|
icon: IconUser,
|
||||||
|
title: "My Account",
|
||||||
|
content: <ProfileSettings user={user} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preferences",
|
||||||
|
icon: IconSettings,
|
||||||
|
title: "Preferences",
|
||||||
|
content: <div>Preferences content</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notifications",
|
||||||
|
icon: IconBell,
|
||||||
|
title: "Notifications",
|
||||||
|
content: <div>Notifications content</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "connections",
|
||||||
|
icon: IconPlugConnected,
|
||||||
|
title: "Connections",
|
||||||
|
content: <div>Connections content</div>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Workspace",
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
icon: IconWorld,
|
||||||
|
title: "General",
|
||||||
|
content: <div>General content</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "members",
|
||||||
|
icon: IconUsers,
|
||||||
|
title: "Members",
|
||||||
|
content: <div>Members content</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security",
|
||||||
|
icon: IconLock,
|
||||||
|
title: "Security",
|
||||||
|
content: <div>Security content</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "identity",
|
||||||
|
icon: IconFingerprint,
|
||||||
|
title: "Identity",
|
||||||
|
content: <div>Identity content</div>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentTab = sections
|
||||||
|
.flatMap((section) => section.tabs)
|
||||||
|
.find((tab) => tab.id === selectedTab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTitle></DialogTitle>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-[1200px] gap-0 p-0">
|
||||||
|
<div className="grid h-[600px] grid-cols-[250px,1fr]">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="border-r bg-muted/50">
|
||||||
|
<ScrollArea className="h-[600px]">
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={userAvatar} alt={displayName} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{displayName[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm font-medium">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<div key={section.title} className="py-2">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{section.tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setSelectedTab(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium",
|
||||||
|
tab.id === selectedTab
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon className="h-4 w-4" />
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{index < sections.length - 1 && (
|
||||||
|
<Separator className="mx-3 my-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex-1">{currentTab?.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
|
@ -8,11 +8,11 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/app/_components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/app/_components/ui/checkbox";
|
||||||
import { createUser } from "@/app/protected/(admin)/dashboard/user-management/action";
|
import { createUser } from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Mail, Lock, Loader2, X } from "lucide-react";
|
import { Mail, Lock, Loader2, X } from "lucide-react";
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table"
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/app/_components/ui/checkbox";
|
||||||
import { MoreHorizontal } from "lucide-react"
|
import { MoreHorizontal } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
@ -12,29 +12,31 @@ import {
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import { formatDate } from "date-fns"
|
import { formatDate } from "date-fns";
|
||||||
|
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string;
|
||||||
email: string
|
email: string;
|
||||||
first_name: string | null
|
first_name: string | null;
|
||||||
last_name: string | null
|
last_name: string | null;
|
||||||
role: string
|
role: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
last_sign_in_at: string | null
|
last_sign_in_at: string | null;
|
||||||
email_confirmed_at: string | null
|
email_confirmed_at: string | null;
|
||||||
is_anonymous: boolean
|
is_anonymous: boolean;
|
||||||
banned_until: string | null
|
banned_until: string | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const columns: ColumnDef<User>[] = [
|
export const columns: ColumnDef<User>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
|
@ -53,7 +55,9 @@ export const columns: ColumnDef<User>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
header: "Email",
|
header: "Email",
|
||||||
cell: ({ row }) => <div className="font-medium">{row.getValue("email")}</div>,
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.getValue("email")}</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "first_name",
|
accessorKey: "first_name",
|
||||||
|
@ -77,20 +81,28 @@ export const columns: ColumnDef<User>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
header: "Created At",
|
header: "Created At",
|
||||||
cell: ({ row }) => <div>{formatDate(new Date(row.getValue("created_at")), "yyyy-MM-dd")}</div>,
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
{formatDate(new Date(row.getValue("created_at")), "yyyy-MM-dd")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "email_confirmed_at",
|
accessorKey: "email_confirmed_at",
|
||||||
header: "Email Verified",
|
header: "Email Verified",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const verified = row.getValue("email_confirmed_at") !== null
|
const verified = row.getValue("email_confirmed_at") !== null;
|
||||||
return <Badge variant={verified ? "default" : "destructive"}>{verified ? "Verified" : "Unverified"}</Badge>
|
return (
|
||||||
|
<Badge variant={verified ? "default" : "destructive"}>
|
||||||
|
{verified ? "Verified" : "Unverified"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const user = row.original
|
const user = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
@ -107,11 +119,12 @@ export const columns: ColumnDef<User>[] = [
|
||||||
<DropdownMenuItem>Reset password</DropdownMenuItem>
|
<DropdownMenuItem>Reset password</DropdownMenuItem>
|
||||||
<DropdownMenuItem>Send magic link</DropdownMenuItem>
|
<DropdownMenuItem>Send magic link</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive">Delete user</DropdownMenuItem>
|
<DropdownMenuItem className="text-destructive">
|
||||||
|
Delete user
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
|
@ -19,14 +19,14 @@ import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/app/_components/ui/table";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
@ -34,14 +34,14 @@ import {
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
Filter,
|
Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/app/_components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/app/_components/ui/select";
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
|
@ -10,13 +10,13 @@ import {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/app/_components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/app/_components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/app/_components/ui/textarea";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { inviteUser } from "@/app/protected/(admin)/dashboard/user-management/action";
|
import { inviteUser } from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface InviteUserDialogProps {
|
interface InviteUserDialogProps {
|
||||||
|
@ -60,7 +60,6 @@ export function InviteUserDialog({
|
||||||
try {
|
try {
|
||||||
await inviteUser({
|
await inviteUser({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
user_metadata: metadata,
|
|
||||||
});
|
});
|
||||||
toast.success("Invitation sent");
|
toast.success("Invitation sent");
|
||||||
onUserInvited();
|
onUserInvited();
|
|
@ -9,10 +9,10 @@ import {
|
||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/app/_components/ui/sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
@ -23,7 +23,7 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/app/_components/ui/alert-dialog";
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
@ -40,7 +40,7 @@ import {
|
||||||
sendMagicLink,
|
sendMagicLink,
|
||||||
sendPasswordRecovery,
|
sendPasswordRecovery,
|
||||||
unbanUser,
|
unbanUser,
|
||||||
} from "@/app/protected/(admin)/dashboard/user-management/action";
|
} from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
interface UserDetailsSheetProps {
|
interface UserDetailsSheetProps {
|
|
@ -4,10 +4,10 @@
|
||||||
// import { useForm } from "react-hook-form"
|
// import { useForm } from "react-hook-form"
|
||||||
// import { z } from "zod"
|
// import { z } from "zod"
|
||||||
|
|
||||||
// import { Button } from "@/components/ui/button"
|
// import { Button } from "@/app/_components/ui/button"
|
||||||
// import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
// import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/app/_components/ui/form"
|
||||||
// import { Input } from "@/components/ui/input"
|
// import { Input } from "@/app/_components/ui/input"
|
||||||
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||||
// import { useState } from "react"
|
// import { useState } from "react"
|
||||||
// import { User } from "./column"
|
// import { User } from "./column"
|
||||||
// import { updateUser } from "../../user-management/action"
|
// import { updateUser } from "../../user-management/action"
|
||||||
|
@ -145,4 +145,3 @@
|
||||||
// </Form>
|
// </Form>
|
||||||
// )
|
// )
|
||||||
// }
|
// }
|
||||||
|
|
|
@ -19,9 +19,9 @@ import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
@ -29,9 +29,9 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchUsers } from "@/app/protected/(admin)/dashboard/user-management/action";
|
import { fetchUsers } from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
||||||
import { User } from "@/src/models/users/users.model";
|
import { User } from "@/src/models/users/users.model";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DataTable } from "./data-table";
|
import { DataTable } from "./data-table";
|
||||||
|
@ -166,17 +166,25 @@ export default function UserManagement() {
|
||||||
if (filters.createdAt === "today") {
|
if (filters.createdAt === "today") {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const createdAt = new Date(user.created_at);
|
const createdAt = user.created_at
|
||||||
|
? user.created_at
|
||||||
|
? new Date(user.created_at)
|
||||||
|
: new Date()
|
||||||
|
: new Date();
|
||||||
if (createdAt < today) return false;
|
if (createdAt < today) return false;
|
||||||
} else if (filters.createdAt === "week") {
|
} else if (filters.createdAt === "week") {
|
||||||
const weekAgo = new Date();
|
const weekAgo = new Date();
|
||||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
const createdAt = new Date(user.created_at);
|
const createdAt = user.created_at
|
||||||
|
? new Date(user.created_at)
|
||||||
|
: new Date();
|
||||||
if (createdAt < weekAgo) return false;
|
if (createdAt < weekAgo) return false;
|
||||||
} else if (filters.createdAt === "month") {
|
} else if (filters.createdAt === "month") {
|
||||||
const monthAgo = new Date();
|
const monthAgo = new Date();
|
||||||
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||||||
const createdAt = new Date(user.created_at);
|
const createdAt = user.created_at
|
||||||
|
? new Date(user.created_at)
|
||||||
|
: new Date();
|
||||||
if (createdAt < monthAgo) return false;
|
if (createdAt < monthAgo) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -435,7 +443,9 @@ export default function UserManagement() {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: User } }) => {
|
cell: ({ row }: { row: { original: User } }) => {
|
||||||
return new Date(row.original.created_at).toLocaleString();
|
return row.original.created_at
|
||||||
|
? new Date(row.original.created_at).toLocaleString()
|
||||||
|
: "N/A";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
|
@ -1,33 +1,36 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Users, UserCheck, UserX } from "lucide-react"
|
|
||||||
import { fetchUsers } from "@/app/protected/(admin)/dashboard/user-management/action"
|
|
||||||
import { User } from "@/src/models/users/users.model"
|
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/app/_components/ui/card";
|
||||||
|
import { Users, UserCheck, UserX } from "lucide-react";
|
||||||
|
import { fetchUsers } from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
||||||
|
import { User } from "@/src/models/users/users.model";
|
||||||
|
|
||||||
function calculateUserStats(users: User[]) {
|
function calculateUserStats(users: User[]) {
|
||||||
const totalUsers = users.length
|
const totalUsers = users.length;
|
||||||
const activeUsers = users.filter((user) => !user.banned_until && user.email_confirmed_at).length
|
const activeUsers = users.filter(
|
||||||
const inactiveUsers = totalUsers - activeUsers
|
(user) => !user.banned_until && user.email_confirmed_at
|
||||||
|
).length;
|
||||||
|
const inactiveUsers = totalUsers - activeUsers;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalUsers,
|
totalUsers,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
inactiveUsers,
|
inactiveUsers,
|
||||||
activePercentage: totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : "0.0",
|
activePercentage:
|
||||||
inactivePercentage: totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : "0.0",
|
totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : "0.0",
|
||||||
}
|
inactivePercentage:
|
||||||
|
totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : "0.0",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserStats() {
|
export function UserStats() {
|
||||||
const { data: users = [], isLoading } = useQuery({
|
const { data: users = [], isLoading } = useQuery({
|
||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
queryFn: fetchUsers,
|
queryFn: fetchUsers,
|
||||||
})
|
});
|
||||||
|
|
||||||
const stats = calculateUserStats(users)
|
const stats = calculateUserStats(users);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
@ -44,7 +47,7 @@ export function UserStats() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
|
@ -66,7 +69,7 @@ export function UserStats() {
|
||||||
subtitle: `${stats.inactivePercentage}% of total users`,
|
subtitle: `${stats.inactivePercentage}% of total users`,
|
||||||
icon: UserX,
|
icon: UserX,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -74,7 +77,9 @@ export function UserStats() {
|
||||||
<Card key={index} className="bg-background border-border">
|
<Card key={index} className="bg-background border-border">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="font-medium text-sm text-muted-foreground">{card.title}</div>
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
<card.icon className="h-4 w-4 text-muted-foreground" />
|
<card.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold mb-2">{card.value}</div>
|
<div className="text-3xl font-bold mb-2">{card.value}</div>
|
||||||
|
@ -83,6 +88,5 @@ export function UserStats() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,24 +9,23 @@ import {
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/app/_components/ui/form";
|
||||||
import {
|
import {
|
||||||
InputOTP,
|
InputOTP,
|
||||||
InputOTPGroup,
|
InputOTPGroup,
|
||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
} from "@/components/ui/input-otp";
|
} from "@/app/_components/ui/input-otp";
|
||||||
import { SubmitButton } from "@/components/submit-button";
|
import { SubmitButton } from "@/app/_components/submit-button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/app/_components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useVerifyOtpForm } from "@/src/controller/auth/verify-otp.controller";
|
import { useVerifyOtpForm } from "@/src/controller/auth/verify-otp.controller";
|
||||||
|
|
||||||
|
|
||||||
interface VerifyOtpFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
interface VerifyOtpFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
|
export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
|
||||||
|
@ -80,7 +79,6 @@ export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
|
||||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
|
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
pendingText="Verifying..."
|
pendingText="Verifying..."
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</SubmitButton>
|
</SubmitButton>
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import ActionSearchBar from "@/components/action-search-bar";
|
import ActionSearchBar from "@/app/_components/action-search-bar";
|
||||||
|
|
||||||
export default function FloatingActionSearchBar() {
|
export default function FloatingActionSearchBar() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
|
@ -2,22 +2,22 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Inbox, Search, ArrowLeft } from "lucide-react";
|
import { Inbox, Search, ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/app/_components/ui/sheet";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from "@/components/ui/avatar";
|
} from "@/app/_components/ui/avatar";
|
||||||
|
|
||||||
interface InboxDrawerProps {
|
interface InboxDrawerProps {
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { type ComponentProps } from "react";
|
import { type ComponentProps } from "react";
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
|
@ -11,13 +11,13 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/app/_components/ui/sidebar";
|
||||||
|
|
||||||
export function TeamSwitcher({
|
export function TeamSwitcher({
|
||||||
teams,
|
teams,
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import { Laptop, Moon, Sun, type LucideIcon } from "lucide-react";
|
import { Laptop, Moon, Sun, type LucideIcon } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue