add profile and remove older project

This commit is contained in:
vergiLgood1 2025-03-05 14:58:15 +07:00
parent dc3c0bebbb
commit 121017dfb1
172 changed files with 1354 additions and 8764 deletions

View File

@ -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

View File

@ -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

View File

@ -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.
[![Deploy with Vercel](https://vercel.com/button)](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)

View File

@ -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.",
);
};

View File

@ -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");
};

View File

@ -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.",
// };
// }
// }

View File

@ -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",
};
}
};

View File

@ -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",
};
}
};

View File

@ -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");
};

View File

@ -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."
);
}
};

View File

@ -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");
};

View File

@ -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.",
};
}
}

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
// );
// }

View File

@ -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

View File

@ -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;
}
}

View File

@ -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

View File

@ -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>
</>
);
}

View File

@ -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

View File

@ -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"
}
}

View File

@ -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>
);
}

View File

@ -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>
// );
// }

View File

@ -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>
);
}

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 }

View File

@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -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)$).*)",
],
};

View File

@ -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

View File

@ -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"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -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;

View File

@ -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,
},
],
};

View File

@ -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
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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: "",
};

View File

@ -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>;

View File

@ -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();

View File

@ -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;

View File

@ -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"]
}

View File

@ -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;

View File

@ -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!,
);

View File

@ -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,
},
});
}
};

View File

@ -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.
}
},
},
},
);
};

View File

@ -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)}`);
}

3
sigap-website/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"files.autoSave": "off"
}

View File

@ -70,34 +70,4 @@ export async function signOut() {
: "Sign out failed. Please try again.",
};
}
}
// 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;
}
}

View File

@ -1,6 +1,6 @@
import { SignInForm } from "@/components/auth/signin-form";
import { Message } from "@/components/form-message";
import { Button } from "@/components/ui/button";
import { SignInForm } from "@/app/_components/auth/signin-form";
import { Message } from "@/app/_components/form-message";
import { Button } from "@/app/_components/ui/button";
import { GalleryVerticalEnd, Globe } from "lucide-react";
export default async function Login(props: { searchParams: Promise<Message> }) {

View File

@ -1,5 +1,4 @@
import { VerifyOtpForm } from "@/components/auth/verify-otp-form";
import { VerifyOtpForm } from "@/app/_components/auth/verify-otp-form";
import { GalleryVerticalEnd } from "lucide-react";
export default async function VerifyOtpPage() {
@ -16,7 +15,7 @@ export default async function VerifyOtpPage() {
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-lg">
<VerifyOtpForm />
<VerifyOtpForm />
</div>
</div>
</div>

View File

@ -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>
</>
);
}

View File

@ -6,11 +6,10 @@ import {
InviteUserParams,
UpdateUserParams,
User,
UserFromSupabase,
UserResponse,
} from "@/src/models/users/users.model";
import { createClient } from "@supabase/supabase-js";
import { createClient as supabaseUser } from "@/utils/supabase/server";
import { createClient } from "@/utils/supabase/server";
import { createAdminClient } from "@/utils/supabase/admin";
// Initialize Supabase client with admin key
@ -41,8 +40,8 @@ export async function fetchUsers(): Promise<User[]> {
}
// get current user
export async function getCurrentUser(): Promise<User> {
const supabase = await supabaseUser();
export async function getCurrentUser(): Promise<UserResponse> {
const supabase = await createClient();
const {
data: { user },
@ -67,17 +66,19 @@ export async function getCurrentUser(): Promise<User> {
throw new Error("User not found");
}
return userDetail;
return {
data: {
user: userDetail,
},
error: null,
};
}
// Create a new user
export async function createUser(
params: CreateUserParams
): Promise<UserFromSupabase> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
): Promise<UserResponse> {
const supabase = createAdminClient();
const { data, error } = await supabase.auth.admin.createUser({
email: params.email,
@ -92,7 +93,10 @@ export async function createUser(
}
return {
...data.user,
data: {
user: data.user,
},
error: null,
};
}
@ -100,11 +104,8 @@ export async function createUser(
export async function updateUser(
userId: string,
params: UpdateUserParams
): Promise<UserFromSupabase> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
): Promise<UserResponse> {
const supabase = createAdminClient();
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
email: params.email,
@ -118,16 +119,16 @@ export async function updateUser(
}
return {
...data.user,
data: {
user: data.user,
},
error: null,
};
}
// Delete a user
export async function deleteUser(userId: string): Promise<void> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
const supabase = createAdminClient();
const { error } = await supabase.auth.admin.deleteUser(userId);
@ -139,10 +140,7 @@ export async function deleteUser(userId: string): Promise<void> {
// Send password recovery email
export async function sendPasswordRecovery(email: string): Promise<void> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
const supabase = createAdminClient();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
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
export async function sendMagicLink(email: string): Promise<void> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
const supabase = createAdminClient();
const { error } = await supabase.auth.signInWithOtp({
email,
@ -175,11 +170,8 @@ export async function sendMagicLink(email: string): Promise<void> {
}
// Ban a user
export async function banUser(userId: string): Promise<UserFromSupabase> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
export async function banUser(userId: string): Promise<UserResponse> {
const supabase = createAdminClient();
// Ban for 100 years (effectively permanent)
const banUntil = new Date();
@ -195,16 +187,16 @@ export async function banUser(userId: string): Promise<UserFromSupabase> {
}
return {
...data.user,
data: {
user: data.user,
},
error: null,
};
}
// Unban a user
export async function unbanUser(userId: string): Promise<UserFromSupabase> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
export async function unbanUser(userId: string): Promise<UserResponse> {
const supabase = createAdminClient();
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
ban_duration: "none",
@ -216,16 +208,16 @@ export async function unbanUser(userId: string): Promise<UserFromSupabase> {
}
return {
...data.user,
data: {
user: data.user,
},
error: null,
};
}
// Invite a user
export async function inviteUser(params: InviteUserParams): Promise<void> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_SECRET!
);
const supabase = createAdminClient();
const { error } = await supabase.auth.admin.inviteUserByEmail(params.email, {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,

View File

@ -1,6 +1,5 @@
import UserManagement from "@/components/admin/users/user-management";
import { UserStats } from "@/components/admin/users/user-stats";
import UserManagement from "@/app/_components/admin/users/user-management";
import { UserStats } from "@/app/_components/admin/users/user-stats";
export default function UsersPage() {
return (

View File

@ -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>
</>
);
}

View File

@ -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 { createClient } from "@/utils/supabase/server";
import { InfoIcon } from "lucide-react";
@ -19,7 +19,6 @@ export default async function ProtectedPage() {
where: {
id: user.id,
},
});
return (
@ -39,7 +38,6 @@ export default async function ProtectedPage() {
<pre className="text-xs font-mono p-3 rounded border overflow-auto">
{JSON.stringify(user, null, 2)}
</pre>
</div>
<div>
<h2 className="font-bold text-2xl mb-4">Next steps</h2>

View File

@ -6,7 +6,7 @@ import React, {
forwardRef,
useImperativeHandle,
} from "react";
import { Input } from "@/components/ui/input";
import { Input } from "@/app/_components/ui/input";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
@ -17,7 +17,7 @@ import {
PlaneTakeoff,
AudioLines,
} from "lucide-react";
import useDebounce from "@/hooks/use-debounce";
import useDebounce from "@/app/_hooks/use-debounce";
interface Action {
id: string;

View File

@ -2,9 +2,9 @@
import * as React from "react";
import { NavMain } from "@/components/admin/navigations/nav-main";
import { NavReports } from "@/components/admin/navigations/nav-report";
import { NavUser } from "@/components/admin/navigations/nav-user";
import { NavMain } from "@/app/_components/admin/navigations/nav-main";
import { NavReports } from "@/app/_components/admin/navigations/nav-report";
import { NavUser } from "@/app/_components/admin/navigations/nav-user";
import {
Sidebar,
@ -12,14 +12,13 @@ import {
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/components/ui/sidebar";
} from "@/app/_components/ui/sidebar";
import { NavPreMain } from "./navigations/nav-pre-main";
import { navData } from "@/prisma/data/nav";
import { TeamSwitcher } from "../team-switcher";
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>) {
const [user, setUser] = React.useState<User | null>(null);
@ -30,7 +29,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
try {
setIsLoading(true);
const userData = await getCurrentUser();
setUser(userData);
setUser(userData.data.user);
} catch (error) {
console.error("Failed to fetch user:", error);
} finally {

View File

@ -6,7 +6,7 @@ import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
} from "@/app/_components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
@ -14,11 +14,11 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from "@/components/ui/sidebar";
} from "@/app/_components/ui/sidebar";
import * as TablerIcons from "@tabler/icons-react";
import { useNavigations } from "@/hooks/use-navigations";
import { useNavigations } from "@/app/_hooks/use-navigations";
interface SubSubItem {
title: string;

View File

@ -8,8 +8,8 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useNavigations } from "@/hooks/use-navigations";
} from "@/app/_components/ui/sidebar";
import { useNavigations } from "@/app/_hooks/use-navigations";
import { Search, Bot, Home } from "lucide-react";
import { IconHome, IconRobot, IconSearch } from "@tabler/icons-react";

View File

@ -12,7 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/_components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
@ -21,7 +21,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
} from "@/app/_components/ui/sidebar";
import * as TablerIcons from "@tabler/icons-react";

View File

@ -1,8 +1,13 @@
"use client";
import { useState } from "react";
import { ChevronsUpDown } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/app/_components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
@ -11,24 +16,21 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/_components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
IconBadgeCc,
IconBell,
IconCreditCard,
IconLogout,
IconSparkles,
} from "@tabler/icons-react";
import { Profile, User } from "@/src/models/users/users.model";
} from "@/app/_components/ui/sidebar";
import { IconLogout, IconSettings, IconSparkles } from "@tabler/icons-react";
import type { User } from "@/src/models/users/users.model";
import { signOut } from "@/app/(auth-pages)/action";
import { SettingsDialog } from "../settings/setting-dialog";
export function NavUser({ user }: { user: User | null }) {
const { isMobile } = useSidebar();
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Use profile data with fallbacks
const firstName = user?.profile?.first_name || "";
@ -54,6 +56,12 @@ export function NavUser({ user }: { user: User | null }) {
return "U";
};
// Handle dialog close after successful profile update
const handleProfileUpdateSuccess = () => {
setIsDialogOpen(false);
// You might want to refresh the user data here
};
return (
<SidebarMenu>
<SidebarMenuItem>
@ -101,28 +109,30 @@ export function NavUser({ user }: { user: User | null }) {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="space-x-2">
<IconSparkles />
<IconSparkles className="size-4" />
<span>Upgrade to Pro</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="space-x-2">
<IconBadgeCc />
<span>Account</span>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2">
<IconCreditCard />
<span>Billing</span>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2">
<IconBell />
<span>Notifications</span>
</DropdownMenuItem>
<SettingsDialog
user={user}
trigger={
<DropdownMenuItem
className="space-x-2"
onSelect={(e) => {
e.preventDefault();
}}
>
<IconSettings className="size-4" />
<span>Settings</span>
</DropdownMenuItem>
}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem className="space-x-2">
<IconLogout />
<DropdownMenuItem onSubmit={signOut} className="space-x-2">
<IconLogout className="size-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -8,11 +8,11 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { createUser } from "@/app/protected/(admin)/dashboard/user-management/action";
} from "@/app/_components/ui/dialog";
import { Button } from "@/app/_components/ui/button";
import { Input } from "@/app/_components/ui/input";
import { Checkbox } from "@/app/_components/ui/checkbox";
import { createUser } from "@/app/(protected)/(admin)/dashboard/user-management/action";
import { toast } from "sonner";
import { Mail, Lock, Loader2, X } from "lucide-react";

View File

@ -1,10 +1,10 @@
"use client"
"use client";
import type { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { MoreHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/app/_components/ui/badge";
import { Checkbox } from "@/app/_components/ui/checkbox";
import { MoreHorizontal } from "lucide-react";
import { Button } from "@/app/_components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@ -12,29 +12,31 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { formatDate } from "date-fns"
} from "@/app/_components/ui/dropdown-menu";
import { formatDate } from "date-fns";
export type User = {
id: string
email: string
first_name: string | null
last_name: string | null
role: string
created_at: string
last_sign_in_at: string | null
email_confirmed_at: string | null
is_anonymous: boolean
banned_until: string | null
}
id: string;
email: string;
first_name: string | null;
last_name: string | null;
role: string;
created_at: string;
last_sign_in_at: string | null;
email_confirmed_at: string | null;
is_anonymous: boolean;
banned_until: string | null;
};
export const columns: ColumnDef<User>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
@ -53,7 +55,9 @@ export const columns: ColumnDef<User>[] = [
{
accessorKey: "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",
@ -77,20 +81,28 @@ export const columns: ColumnDef<User>[] = [
{
accessorKey: "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",
header: "Email Verified",
cell: ({ row }) => {
const verified = row.getValue("email_confirmed_at") !== null
return <Badge variant={verified ? "default" : "destructive"}>{verified ? "Verified" : "Unverified"}</Badge>
const verified = row.getValue("email_confirmed_at") !== null;
return (
<Badge variant={verified ? "default" : "destructive"}>
{verified ? "Verified" : "Unverified"}
</Badge>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const user = row.original
const user = row.original;
return (
<DropdownMenu>
@ -107,11 +119,12 @@ export const columns: ColumnDef<User>[] = [
<DropdownMenuItem>Reset password</DropdownMenuItem>
<DropdownMenuItem>Send magic link</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">Delete user</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
Delete user
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
);
},
},
]
];

View File

@ -19,14 +19,14 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
} from "@/app/_components/ui/table";
import { Button } from "@/app/_components/ui/button";
import { Input } from "@/app/_components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/_components/ui/dropdown-menu";
import {
ChevronLeft,
ChevronRight,
@ -34,14 +34,14 @@ import {
ChevronsRight,
Filter,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Skeleton } from "@/app/_components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
} from "@/app/_components/ui/select";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];

View File

@ -10,13 +10,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
} from "@/app/_components/ui/dialog";
import { Button } from "@/app/_components/ui/button";
import { Label } from "@/app/_components/ui/label";
import { Input } from "@/app/_components/ui/input";
import { Textarea } from "@/app/_components/ui/textarea";
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";
interface InviteUserDialogProps {
@ -60,7 +60,6 @@ export function InviteUserDialog({
try {
await inviteUser({
email: formData.email,
user_metadata: metadata,
});
toast.success("Invitation sent");
onUserInvited();

View File

@ -9,10 +9,10 @@ import {
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
} from "@/app/_components/ui/sheet";
import { Button } from "@/app/_components/ui/button";
import { Badge } from "@/app/_components/ui/badge";
import { Separator } from "@/app/_components/ui/separator";
import {
AlertDialog,
AlertDialogAction,
@ -23,7 +23,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
} from "@/app/_components/ui/alert-dialog";
import {
Mail,
Trash2,
@ -40,7 +40,7 @@ import {
sendMagicLink,
sendPasswordRecovery,
unbanUser,
} from "@/app/protected/(admin)/dashboard/user-management/action";
} from "@/app/(protected)/(admin)/dashboard/user-management/action";
import { format } from "date-fns";
interface UserDetailsSheetProps {

View File

@ -4,10 +4,10 @@
// import { useForm } from "react-hook-form"
// import { z } from "zod"
// import { Button } from "@/components/ui/button"
// import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
// import { Input } from "@/components/ui/input"
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
// import { Button } from "@/app/_components/ui/button"
// import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/app/_components/ui/form"
// import { Input } from "@/app/_components/ui/input"
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
// import { useState } from "react"
// import { User } from "./column"
// import { updateUser } from "../../user-management/action"
@ -145,4 +145,3 @@
// </Form>
// )
// }

View File

@ -19,9 +19,9 @@ import {
ShieldAlert,
ListFilter,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { Input } from "@/app/_components/ui/input";
import { Badge } from "@/app/_components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
@ -29,9 +29,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu";
} from "@/app/_components/ui/dropdown-menu";
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 { toast } from "sonner";
import { DataTable } from "./data-table";
@ -166,17 +166,25 @@ export default function UserManagement() {
if (filters.createdAt === "today") {
const today = new Date();
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;
} else if (filters.createdAt === "week") {
const weekAgo = new Date();
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;
} else if (filters.createdAt === "month") {
const monthAgo = new Date();
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;
}
}
@ -435,7 +443,9 @@ export default function UserManagement() {
</div>
),
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";
},
},
{

View File

@ -1,33 +1,36 @@
"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"
"use client";
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[]) {
const totalUsers = users.length
const activeUsers = users.filter((user) => !user.banned_until && user.email_confirmed_at).length
const inactiveUsers = totalUsers - activeUsers
const totalUsers = users.length;
const activeUsers = users.filter(
(user) => !user.banned_until && user.email_confirmed_at
).length;
const inactiveUsers = totalUsers - activeUsers;
return {
totalUsers,
activeUsers,
inactiveUsers,
activePercentage: totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : "0.0",
inactivePercentage: totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : "0.0",
}
activePercentage:
totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : "0.0",
inactivePercentage:
totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : "0.0",
};
}
export function UserStats() {
const { data: users = [], isLoading } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
})
});
const stats = calculateUserStats(users)
const stats = calculateUserStats(users);
if (isLoading) {
return (
@ -44,7 +47,7 @@ export function UserStats() {
</Card>
))}
</>
)
);
}
const cards = [
@ -66,7 +69,7 @@ export function UserStats() {
subtitle: `${stats.inactivePercentage}% of total users`,
icon: UserX,
},
]
];
return (
<>
@ -74,7 +77,9 @@ export function UserStats() {
<Card key={index} className="bg-background border-border">
<CardContent className="p-6">
<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" />
</div>
<div className="text-3xl font-bold mb-2">{card.value}</div>
@ -83,6 +88,5 @@ export function UserStats() {
</Card>
))}
</>
)
);
}

View File

@ -9,30 +9,29 @@ import {
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
} from "@/app/_components/ui/form";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { SubmitButton } from "@/components/submit-button";
} from "@/app/_components/ui/input-otp";
import { SubmitButton } from "@/app/_components/submit-button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
} from "@/app/_components/ui/card";
import { cn } from "@/lib/utils";
import { useVerifyOtpForm } from "@/src/controller/auth/verify-otp.controller";
interface VerifyOtpFormProps extends React.HTMLAttributes<HTMLDivElement> {}
export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
const searchParams = useSearchParams();
const email = searchParams.get("email") || "";
const { form, isSubmitting, message, onSubmit } = useVerifyOtpForm(email);
return (
@ -80,7 +79,6 @@ export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
pendingText="Verifying..."
disabled={isSubmitting}
>
Submit
</SubmitButton>
@ -98,4 +96,4 @@ export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
</div>
</div>
);
}
}

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react";
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() {
const [isOpen, setIsOpen] = useState(false);

View File

@ -2,22 +2,22 @@
import * as React from "react";
import { Inbox, Search, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Button } from "@/app/_components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
} from "@/app/_components/ui/sheet";
import { Input } from "@/app/_components/ui/input";
import { ScrollArea } from "@/app/_components/ui/scroll-area";
import { Badge } from "@/app/_components/ui/badge";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
} from "@/app/_components/ui/avatar";
interface InboxDrawerProps {
showTitle?: boolean;

View File

@ -1,6 +1,6 @@
"use client";
import { Button } from "@/components/ui/button";
import { Button } from "@/app/_components/ui/button";
import { type ComponentProps } from "react";
import { useFormStatus } from "react-dom";

View File

@ -11,13 +11,13 @@ import {
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/_components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
} from "@/app/_components/ui/sidebar";
export function TeamSwitcher({
teams,

View File

@ -2,14 +2,14 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Button } from "@/app/_components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/_components/ui/dropdown-menu";
import { Laptop, Moon, Sun, type LucideIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState, useMemo } from "react";

Some files were not shown because too many files have changed in this diff Show More