feat: add login action & adjust auth middleware
This commit is contained in:
parent
4476f4d664
commit
431b56b779
|
|
@ -20,6 +20,7 @@
|
||||||
"framer-motion": "^12.31.0",
|
"framer-motion": "^12.31.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-auth": "^4.24.13",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|
@ -1903,6 +1904,15 @@
|
||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/adapter-neon": {
|
"node_modules/@prisma/adapter-neon": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-neon/-/adapter-neon-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/adapter-neon/-/adapter-neon-7.3.0.tgz",
|
||||||
|
|
@ -6856,6 +6866,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/create-require": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
|
|
@ -9121,6 +9140,15 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -9827,6 +9855,38 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "4.24.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz",
|
||||||
|
"integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@panva/hkdf": "^1.0.2",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"jose": "^4.15.5",
|
||||||
|
"oauth": "^0.9.15",
|
||||||
|
"openid-client": "^5.4.0",
|
||||||
|
"preact": "^10.6.3",
|
||||||
|
"preact-render-to-string": "^5.1.19",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/core": "0.34.3",
|
||||||
|
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"react": "^17.0.2 || ^18 || ^19",
|
||||||
|
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@auth/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
|
|
@ -9894,6 +9954,12 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -9904,6 +9970,15 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|
@ -10024,6 +10099,48 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -10367,6 +10484,28 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.28.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||||
|
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-format": "^3.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|
@ -10377,6 +10516,12 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.3.0.tgz",
|
||||||
|
|
@ -12059,6 +12204,15 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"framer-motion": "^12.31.0",
|
"framer-motion": "^12.31.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-auth": "^4.24.13",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import GoogleProvider from "next-auth/providers/google";
|
||||||
|
|
||||||
|
export const authOptions = {
|
||||||
|
providers: [
|
||||||
|
GoogleProvider({
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
|
|
@ -1,71 +1,17 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { redirect } from "next/navigation";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { LoginForm } from "@/src/components/auth/LoginForm";
|
||||||
import {
|
import { getServerSession } from "next-auth";
|
||||||
Card,
|
import { authOptions } from "../../api/auth/[...nextauth]/route";
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "../../../components/ui/card";
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "../../../components/ui/field";
|
|
||||||
import { Input } from "../../../components/ui/input";
|
|
||||||
|
|
||||||
export function LoginPage({
|
export default async function LoginPage({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div">) {
|
}: React.ComponentProps<"div">) {
|
||||||
return (
|
const session = await getServerSession(authOptions);
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
|
||||||
<Card>
|
if (session) {
|
||||||
<CardHeader className="text-center">
|
redirect("/dashboard");
|
||||||
<CardTitle>Login to SENTILAISES.</CardTitle>
|
}
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
return <LoginForm className={className} {...props} />;
|
||||||
<form>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="m@example.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
Forgot your password?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="°°°°°°"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<Button type="submit">Login</Button>
|
|
||||||
<Button variant="outline" type="button">
|
|
||||||
Login with Google
|
|
||||||
</Button>
|
|
||||||
<FieldDescription className="text-center">
|
|
||||||
Don't have an account? <a href="#">Sign up</a>
|
|
||||||
</FieldDescription>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,13 @@
|
||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { getServerSession } from "next-auth";
|
||||||
import {
|
import DashboardClient from "@/src/components/dashboards/DashboardClient";
|
||||||
brandData,
|
|
||||||
reviewData,
|
|
||||||
sentimentDistribution,
|
|
||||||
trendData,
|
|
||||||
wordCloudData,
|
|
||||||
} from "./lib/data";
|
|
||||||
import { Header } from "../../components/dashboards/Header";
|
|
||||||
import {
|
|
||||||
MessageSquareText,
|
|
||||||
Minus,
|
|
||||||
ThumbsDown,
|
|
||||||
ThumbsUp,
|
|
||||||
TrendingUp,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { StatCard } from "../../components/dashboards/StatCard";
|
|
||||||
import { TrendChart } from "../../components/dashboards/TrendChart";
|
|
||||||
import { SentimentChart } from "../../components/dashboards/SentimentChart";
|
|
||||||
import { WordCloud } from "../../components/dashboards/WordCloud";
|
|
||||||
import { ModelInfo } from "../../components/dashboards/ModelInfo";
|
|
||||||
import { SentimentAnalyzer } from "../../components/dashboards/SentimentAnalyzer";
|
|
||||||
import { BrandFilter } from "../../components/dashboards/BrandFilter";
|
|
||||||
import { ReviewTable } from "../../components/dashboards/ReviewTable";
|
|
||||||
import { getClassificationReport } from "./lib/actions";
|
|
||||||
import { ModelDB } from "@/src/types";
|
|
||||||
import { ModelInfoSkeleton } from "../../components/skeletons/ModelInfoSkeleton";
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
const session = await getServerSession();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [modelData, setModelData] = useState<ModelDB[]>([]);
|
|
||||||
const totalReviews = sentimentDistribution.reduce(
|
|
||||||
(sum, s) => sum + s.value,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const positiveCount =
|
|
||||||
sentimentDistribution.find((s) => s.name === "Positif")?.value || 0;
|
|
||||||
const negativeCount =
|
|
||||||
sentimentDistribution.find((s) => s.name === "Negatif")?.value || 0;
|
|
||||||
const neutralCount =
|
|
||||||
sentimentDistribution.find((s) => s.name === "Netral")?.value || 0;
|
|
||||||
|
|
||||||
const filteredReviews = selectedBrand
|
if (!session) {
|
||||||
? reviewData.filter((r) => r.brand === selectedBrand)
|
redirect("/");
|
||||||
: reviewData;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const data = await getClassificationReport();
|
|
||||||
setModelData(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch model data", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
fetchData();
|
return <DashboardClient />;
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div
|
|
||||||
className="mb-8 rounded-2xl p-8 text-center"
|
|
||||||
style={{ background: "hsl(var(--primary))" }}
|
|
||||||
>
|
|
||||||
<h2 className="mb-2 text-3xl font-bold text-white md:text-4xl">
|
|
||||||
Analisis Sentimen Ulasan Laptop
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-2xl text-lg text-white/80">
|
|
||||||
Sistem klasifikasi sentimen menggunakan algoritma XGBoost untuk
|
|
||||||
menganalisis ulasan produk laptop pada platform Tokopedia
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex items-center justify-center gap-4 text-sm text-white/70">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<TrendingUp className="h-4 w-4" />
|
|
||||||
Akurasi 92.4%
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>XGBoost + TF-IDF</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Real-time Analysis</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
title="Total Ulasan"
|
|
||||||
value={totalReviews}
|
|
||||||
icon={MessageSquareText}
|
|
||||||
trend={{ value: 12.5, isPositive: true }}
|
|
||||||
delay={0}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Sentimen Positif"
|
|
||||||
value={positiveCount}
|
|
||||||
suffix={`(${((positiveCount / totalReviews) * 100).toFixed(1)}%)`}
|
|
||||||
icon={ThumbsUp}
|
|
||||||
variant="positive"
|
|
||||||
delay={100}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Sentimen Negatif"
|
|
||||||
value={negativeCount}
|
|
||||||
suffix={`(${((negativeCount / totalReviews) * 100).toFixed(1)}%)`}
|
|
||||||
icon={ThumbsDown}
|
|
||||||
variant="negative"
|
|
||||||
delay={200}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Sentimen Netral"
|
|
||||||
value={neutralCount}
|
|
||||||
suffix={`(${((neutralCount / totalReviews) * 100).toFixed(1)}%)`}
|
|
||||||
icon={Minus}
|
|
||||||
variant="neutral"
|
|
||||||
delay={300}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Section */}
|
|
||||||
<div className="mb-8 grid gap-6 lg:grid-cols-3">
|
|
||||||
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
|
|
||||||
<h3 className="mb-4 text-lg font-semibold">
|
|
||||||
Tren Sentimen Bulanan
|
|
||||||
</h3>
|
|
||||||
<TrendChart data={trendData} />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border bg-card p-6">
|
|
||||||
<h3 className="mb-4 text-lg font-semibold">Distribusi Sentimen</h3>
|
|
||||||
<SentimentChart data={sentimentDistribution} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Word Cloud & Model Info */}
|
|
||||||
<div className="mb-8 grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* Slot Kata Kunci */}
|
|
||||||
<div className="rounded-xl border bg-card p-6">
|
|
||||||
<h3 className="mb-4 text-lg font-semibold">Kata Kunci Populer</h3>
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
Kata-kata yang sering muncul dalam ulasan berdasarkan kategori
|
|
||||||
sentimen
|
|
||||||
</p>
|
|
||||||
<WordCloud words={wordCloudData} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<ModelInfoSkeleton />
|
|
||||||
) : modelData.length > 0 ? (
|
|
||||||
<ModelInfo data={modelData} />
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground">
|
|
||||||
Data model tidak tersedia.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sentiment Analyzer */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<SentimentAnalyzer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reviews Section */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Ulasan Terbaru</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Hasil klasifikasi sentimen ulasan produk laptop
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<BrandFilter
|
|
||||||
brands={brandData}
|
|
||||||
selectedBrand={selectedBrand}
|
|
||||||
onSelect={setSelectedBrand}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ReviewTable reviews={filteredReviews} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="mt-12 border-t pt-8">
|
|
||||||
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground text-center lg:text-start md:text-start">
|
|
||||||
SentiLaptop - Analisis Sentimen
|
|
||||||
</p>
|
|
||||||
<p>Skripsi oleh Syafrizal Wd Mahendra (E41222719)</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center lg:text-end md:text-end">
|
|
||||||
<p>Politeknik Negeri Jember</p>
|
|
||||||
<p>PSDKU Teknik Informatika Kampus 3 Nganjuk</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Providers from "./providers";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -27,7 +28,9 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${inter.className} font-sans`}>{children}</body>
|
<body className={`${inter.variable} font-sans`}>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
'use client";';
|
import { redirect } from "next/navigation";
|
||||||
import { LoginPage } from "./auth/login/page";
|
import { LoginForm } from "../components/auth/LoginForm";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "./api/auth/[...nextauth]/route";
|
||||||
|
|
||||||
export default function Home() {
|
export default async function Home() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<LoginPage />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "../ui/field";
|
||||||
|
|
||||||
|
export function LoginForm({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Login to SENTILAISES.</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="°°°°°°"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Button type="submit">Login</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
signIn("google", { callbackUrl: "/dashboard" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Login with Google
|
||||||
|
</Button>
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
Don't have an account? <a href="#">Sign up</a>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
import {
|
||||||
|
MessageSquareText,
|
||||||
|
Minus,
|
||||||
|
ThumbsDown,
|
||||||
|
ThumbsUp,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { StatCard } from "./StatCard";
|
||||||
|
import { ModelDB } from "@/src/types";
|
||||||
|
import {
|
||||||
|
brandData,
|
||||||
|
reviewData,
|
||||||
|
sentimentDistribution,
|
||||||
|
trendData,
|
||||||
|
wordCloudData,
|
||||||
|
} from "@/src/app/dashboard/lib/data";
|
||||||
|
import { getClassificationReport } from "@/src/app/dashboard/lib/actions";
|
||||||
|
import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton";
|
||||||
|
import { ModelInfo } from "./ModelInfo";
|
||||||
|
import { SentimentAnalyzer } from "./SentimentAnalyzer";
|
||||||
|
import { BrandFilter } from "./BrandFilter";
|
||||||
|
import { ReviewTable } from "./ReviewTable";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const TrendChart = dynamic(
|
||||||
|
() => import("./TrendChart").then((mod) => ({ default: mod.TrendChart })),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
const WordCloud = dynamic(
|
||||||
|
() => import("./WordCloud").then((mod) => ({ default: mod.WordCloud })),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
const SentimentChart = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./SentimentChart").then((mod) => ({ default: mod.SentimentChart })),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DashboardClient() {
|
||||||
|
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modelData, setModelData] = useState<ModelDB[]>([]);
|
||||||
|
|
||||||
|
const totalReviews = sentimentDistribution.reduce(
|
||||||
|
(sum, s) => sum + s.value,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const positiveCount =
|
||||||
|
sentimentDistribution.find((s) => s.name === "Positif")?.value || 0;
|
||||||
|
const negativeCount =
|
||||||
|
sentimentDistribution.find((s) => s.name === "Negatif")?.value || 0;
|
||||||
|
const neutralCount =
|
||||||
|
sentimentDistribution.find((s) => s.name === "Netral")?.value || 0;
|
||||||
|
|
||||||
|
const filteredReviews = selectedBrand
|
||||||
|
? reviewData.filter((r) => r.brand === selectedBrand)
|
||||||
|
: reviewData;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const data = await getClassificationReport();
|
||||||
|
setModelData(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch model data", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div
|
||||||
|
className="mb-8 rounded-2xl p-8 text-center"
|
||||||
|
style={{ background: "hsl(var(--primary))" }}
|
||||||
|
>
|
||||||
|
<h2 className="mb-2 text-3xl font-bold text-white md:text-4xl">
|
||||||
|
Analisis Sentimen Ulasan Laptop
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-2xl text-lg text-white/80">
|
||||||
|
Sistem klasifikasi sentimen menggunakan algoritma XGBoost untuk
|
||||||
|
menganalisis ulasan produk laptop pada platform Tokopedia
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-4 text-sm text-white/70">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Akurasi 92.4%
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>XGBoost + TF-IDF</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Real-time Analysis</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Total Ulasan"
|
||||||
|
value={totalReviews}
|
||||||
|
icon={MessageSquareText}
|
||||||
|
trend={{ value: 12.5, isPositive: true }}
|
||||||
|
delay={0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Sentimen Positif"
|
||||||
|
value={positiveCount}
|
||||||
|
suffix={`(${((positiveCount / totalReviews) * 100).toFixed(1)}%)`}
|
||||||
|
icon={ThumbsUp}
|
||||||
|
variant="positive"
|
||||||
|
delay={100}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Sentimen Negatif"
|
||||||
|
value={negativeCount}
|
||||||
|
suffix={`(${((negativeCount / totalReviews) * 100).toFixed(1)}%)`}
|
||||||
|
icon={ThumbsDown}
|
||||||
|
variant="negative"
|
||||||
|
delay={200}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Sentimen Netral"
|
||||||
|
value={neutralCount}
|
||||||
|
suffix={`(${((neutralCount / totalReviews) * 100).toFixed(1)}%)`}
|
||||||
|
icon={Minus}
|
||||||
|
variant="neutral"
|
||||||
|
delay={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<div className="mb-8 grid gap-6 lg:grid-cols-3">
|
||||||
|
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">
|
||||||
|
Tren Sentimen Bulanan
|
||||||
|
</h3>
|
||||||
|
<TrendChart data={trendData} />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border bg-card p-6">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">Distribusi Sentimen</h3>
|
||||||
|
<SentimentChart data={sentimentDistribution} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Word Cloud & Model Info */}
|
||||||
|
<div className="mb-8 grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Slot Kata Kunci */}
|
||||||
|
<div className="rounded-xl border bg-card p-6">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">Kata Kunci Populer</h3>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Kata-kata yang sering muncul dalam ulasan berdasarkan kategori
|
||||||
|
sentimen
|
||||||
|
</p>
|
||||||
|
<WordCloud words={wordCloudData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ModelInfoSkeleton />
|
||||||
|
) : modelData.length > 0 ? (
|
||||||
|
<ModelInfo data={modelData} />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground">
|
||||||
|
Data model tidak tersedia.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sentiment Analyzer */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<SentimentAnalyzer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Ulasan Terbaru</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Hasil klasifikasi sentimen ulasan produk laptop
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BrandFilter
|
||||||
|
brands={brandData}
|
||||||
|
selectedBrand={selectedBrand}
|
||||||
|
onSelect={setSelectedBrand}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ReviewTable reviews={filteredReviews} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-12 border-t pt-8">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground text-center lg:text-start md:text-start">
|
||||||
|
SentiLaptop - Analisis Sentimen
|
||||||
|
</p>
|
||||||
|
<p>Skripsi oleh Syafrizal Wd Mahendra (E41222719)</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center lg:text-end md:text-end">
|
||||||
|
<p>Politeknik Negeri Jember</p>
|
||||||
|
<p>PSDKU Teknik Informatika Kampus 3 Nganjuk</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
"use client";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Database,
|
Database,
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
|
@ -84,7 +86,7 @@ export function Header() {
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer gap-2 text-destructive focus:bg-destructive/10 focus:text-red-500 transition-colors"
|
className="cursor-pointer gap-2 text-destructive focus:bg-destructive/10 focus:text-red-500 transition-colors"
|
||||||
onClick={() => redirect("/")}
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
|
|
|
||||||
|
|
@ -86,20 +86,26 @@ export function ReviewTable({ reviews }: ReviewTableProps) {
|
||||||
className="animate-fade-in"
|
className="animate-fade-in"
|
||||||
style={{ animationDelay: `${index * 50}ms` }}
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="max-w-40 overflow-hidden">
|
||||||
<div>
|
<div className="max-w-40">
|
||||||
<p className="font-medium text-foreground">{review.brand}</p>
|
<p className="font-medium text-foreground">
|
||||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
{review.brand}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{review.product}
|
{review.product}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<p className="line-clamp-2 text-sm">{review.review}</p>
|
<TableCell className="max-w-60 overflow-hidden">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-sm line-clamp-2 wrap-break-word truncate">
|
||||||
|
{review.review}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||||
{review.date}
|
{review.date}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>{renderStars(review.rating)}</TableCell>
|
<TableCell>{renderStars(review.rating)}</TableCell>
|
||||||
<TableCell>{getSentimentBadge(review.sentiment)}</TableCell>
|
<TableCell>{getSentimentBadge(review.sentiment)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export function TrendChart({ data }: TrendChartProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[350px] min-h-[350px] w-full">
|
<div className="h-87.5 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart
|
<AreaChart
|
||||||
data={data}
|
data={data}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|
@ -35,8 +35,8 @@ const buttonVariants = cva(
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
|
|
@ -46,9 +46,9 @@ function Button({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|
@ -58,7 +58,7 @@ function Button({
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
|
@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
className={cn("[&_tr]:border-b", className)}
|
className={cn("[&_tr]:border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
|
@ -36,7 +36,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
|
@ -45,11 +45,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
data-slot="table-footer"
|
data-slot="table-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
|
@ -58,11 +58,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
|
@ -71,11 +71,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
|
@ -84,11 +84,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({
|
function TableCaption({
|
||||||
|
|
@ -101,7 +101,7 @@ function TableCaption({
|
||||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -113,4 +113,4 @@ export {
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue