first commit
This commit is contained in:
parent
57a3e75ace
commit
09e47cac29
|
|
@ -39,3 +39,5 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@shadcn-editor": "https://shadcn-editor.vercel.app/r/{name}.json"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
|
|
@ -9,19 +9,40 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lexical/react": "^0.38.2",
|
||||
"@lexical/rich-text": "^0.38.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jodit-react": "^5.2.38",
|
||||
"lexical": "^0.38.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"mysql2": "^3.15.3",
|
||||
"next": "15.5.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.6"
|
||||
"react-hook-form": "^7.66.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"typeorm": "^0.3.27",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.6",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { initDB } from "@/utils/init-db";
|
||||
import { AppDataSource } from "@/utils/data-source";
|
||||
import { Sku } from "@/entities/Sku";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await initDB();
|
||||
const repo = AppDataSource.getRepository(Sku);
|
||||
|
||||
// Lấy id từ pathname
|
||||
// /api/skus/123 => ["", "api", "skus", "123"]
|
||||
const segments = req.nextUrl.pathname.split("/");
|
||||
const id = Number(segments[segments.length - 1]);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sku = await repo.findOne({
|
||||
where: { id },
|
||||
select: [
|
||||
"id",
|
||||
"sku",
|
||||
"status",
|
||||
"normalized_title",
|
||||
"normalized_short_description",
|
||||
"normalized_html",
|
||||
],
|
||||
});
|
||||
|
||||
if (!sku) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(sku);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { initDB } from "@/utils/init-db";
|
||||
import { AppDataSource } from "@/utils/data-source";
|
||||
import { Sku } from "@/entities/Sku";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await initDB();
|
||||
const repo = AppDataSource.getRepository(Sku);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = Number(searchParams.get("page") || 1);
|
||||
const limit = Number(searchParams.get("limit") || 10);
|
||||
const search = searchParams.get("search")?.trim() || "";
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = repo.createQueryBuilder("sku");
|
||||
|
||||
if (search) {
|
||||
queryBuilder.where("sku.sku LIKE :search", { search: `%${search}%` });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.select([
|
||||
"sku.id",
|
||||
"sku.sku",
|
||||
"sku.normalized_title",
|
||||
"sku.normalized_short_description",
|
||||
"sku.status",
|
||||
"sku.normalized_html",
|
||||
])
|
||||
.orderBy("sku.created_at", "DESC")
|
||||
.addOrderBy("sku.id", "DESC") // <--- quan trọng, tránh trùng record
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
await initDB();
|
||||
const body = await req.json();
|
||||
|
||||
const repo = AppDataSource.getRepository(Sku);
|
||||
|
||||
const sku = await repo.findOneBy({ id: body.id });
|
||||
if (!sku) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const result = await repo.update({ id: sku.id }, { ...body });
|
||||
|
||||
if (!result)
|
||||
return NextResponse.json(
|
||||
{ error: "Can't update " + sku.sku },
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
error: false,
|
||||
message: `Updated ${sku.sku} success`,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { SerializedEditorState } from "lexical"
|
||||
|
||||
import { Editor } from "@/components/blocks/editor-00/editor"
|
||||
|
||||
export const initialValue = {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: "normal",
|
||||
style: "",
|
||||
text: "Hello World 🚀",
|
||||
type: "text",
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: "ltr",
|
||||
format: "",
|
||||
indent: 0,
|
||||
type: "paragraph",
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: "ltr",
|
||||
format: "",
|
||||
indent: 0,
|
||||
type: "root",
|
||||
version: 1,
|
||||
},
|
||||
} as unknown as SerializedEditorState
|
||||
|
||||
export default function EditorPage() {
|
||||
const [editorState, setEditorState] =
|
||||
useState<SerializedEditorState>(initialValue)
|
||||
return (
|
||||
<Editor
|
||||
editorSerializedState={editorState}
|
||||
onSerializedChange={(value) => setEditorState(value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,26 +1,122 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
|
@ -25,9 +26,11 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster expand={true} richColors={true} position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import * as React from "react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="w-screen h-screen flex items-center justify-center">
|
||||
<Spinner className="size-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/app/page.tsx
150
src/app/page.tsx
|
|
@ -1,103 +1,59 @@
|
|||
import Image from "next/image";
|
||||
import HomePagination from "@/components/home/home-pagination";
|
||||
import Item from "@/components/home/item";
|
||||
import SKUListSidebar from "@/components/home/sku-list-sidebar";
|
||||
import { Sku } from "@/entities/Sku";
|
||||
import axios from "@/lib/axios";
|
||||
import { use } from "react";
|
||||
|
||||
const getData = async (query: { page: number; search: string }) => {
|
||||
const { data } = await axios({
|
||||
method: "GET",
|
||||
url: "skus",
|
||||
params: query,
|
||||
});
|
||||
|
||||
return data || [];
|
||||
};
|
||||
|
||||
export default function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; search?: string }>;
|
||||
}) {
|
||||
const query = use(searchParams);
|
||||
|
||||
const page = Number(query?.page) || 1;
|
||||
const search = query?.search || "";
|
||||
|
||||
const data = use(getData({ page, search }));
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="grid grid-cols-12 min-h-[1000px] px-5 gap-4 py-2 pb-20">
|
||||
<div className="col-span-8 display flex flex-col gap-10">
|
||||
{(data.data || []).map(
|
||||
(
|
||||
item: Pick<
|
||||
Sku,
|
||||
| "id"
|
||||
| "normalized_title"
|
||||
| "normalized_short_description"
|
||||
| "sku"
|
||||
| "normalized_html"
|
||||
| "status"
|
||||
>,
|
||||
index: number
|
||||
) => {
|
||||
return <Item key={item?.sku || index} data={item} />;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
<div className="col-span-4 -my-2 border-l">
|
||||
<div className="sticky z-50 top-0">
|
||||
<SKUListSidebar data={data?.data} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomePagination page={page} totalPages={data.pagination.totalPages} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
InitialConfigType,
|
||||
LexicalComposer,
|
||||
} from "@lexical/react/LexicalComposer"
|
||||
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
|
||||
import { EditorState, SerializedEditorState } from "lexical"
|
||||
|
||||
import { editorTheme } from "@/components/editor/themes/editor-theme"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
import { nodes } from "./nodes"
|
||||
import { Plugins } from "./plugins"
|
||||
|
||||
const editorConfig: InitialConfigType = {
|
||||
namespace: "Editor",
|
||||
theme: editorTheme,
|
||||
nodes,
|
||||
onError: (error: Error) => {
|
||||
console.error(error)
|
||||
},
|
||||
}
|
||||
|
||||
export function Editor({
|
||||
editorState,
|
||||
editorSerializedState,
|
||||
onChange,
|
||||
onSerializedChange,
|
||||
}: {
|
||||
editorState?: EditorState
|
||||
editorSerializedState?: SerializedEditorState
|
||||
onChange?: (editorState: EditorState) => void
|
||||
onSerializedChange?: (editorSerializedState: SerializedEditorState) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-background overflow-hidden rounded-lg border shadow">
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
...editorConfig,
|
||||
...(editorState ? { editorState } : {}),
|
||||
...(editorSerializedState
|
||||
? { editorState: JSON.stringify(editorSerializedState) }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Plugins />
|
||||
|
||||
<OnChangePlugin
|
||||
ignoreSelectionChange={true}
|
||||
onChange={(editorState) => {
|
||||
onChange?.(editorState)
|
||||
onSerializedChange?.(editorState.toJSON())
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</LexicalComposer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
||||
import {
|
||||
Klass,
|
||||
LexicalNode,
|
||||
LexicalNodeReplacement,
|
||||
ParagraphNode,
|
||||
TextNode,
|
||||
} from "lexical"
|
||||
|
||||
export const nodes: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement> =
|
||||
[HeadingNode, ParagraphNode, TextNode, QuoteNode]
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { useState } from "react"
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
|
||||
|
||||
import { ContentEditable } from "@/components/editor/editor-ui/content-editable"
|
||||
|
||||
export function Plugins() {
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
|
||||
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* toolbar plugins */}
|
||||
<div className="relative">
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div className="">
|
||||
<div className="" ref={onRef}>
|
||||
<ContentEditable placeholder={"Start typing ..."} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{/* editor plugins */}
|
||||
</div>
|
||||
{/* actions plugins */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { JSX } from "react"
|
||||
import { ContentEditable as LexicalContentEditable } from "@lexical/react/LexicalContentEditable"
|
||||
|
||||
type Props = {
|
||||
placeholder: string
|
||||
className?: string
|
||||
placeholderClassName?: string
|
||||
}
|
||||
|
||||
export function ContentEditable({
|
||||
placeholder,
|
||||
className,
|
||||
placeholderClassName,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<LexicalContentEditable
|
||||
className={
|
||||
className ??
|
||||
`ContentEditable__root relative block min-h-72 min-h-full overflow-auto px-8 py-4 focus:outline-none`
|
||||
}
|
||||
aria-placeholder={placeholder}
|
||||
placeholder={
|
||||
<div
|
||||
className={
|
||||
placeholderClassName ??
|
||||
`text-muted-foreground pointer-events-none absolute top-0 left-0 overflow-hidden px-8 py-[18px] text-ellipsis select-none`
|
||||
}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
.EditorTheme__code {
|
||||
background-color: transparent;
|
||||
font-family: Menlo, Consolas, Monaco, monospace;
|
||||
display: block;
|
||||
padding: 8px 8px 8px 52px;
|
||||
line-height: 1.53;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #ccc;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
tab-size: 2;
|
||||
}
|
||||
.EditorTheme__code:before {
|
||||
content: attr(data-gutter);
|
||||
position: absolute;
|
||||
background-color: transparent;
|
||||
border-right: 1px solid #ccc;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding: 8px;
|
||||
color: #777;
|
||||
white-space: pre-wrap;
|
||||
text-align: right;
|
||||
min-width: 25px;
|
||||
}
|
||||
.EditorTheme__table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
overflow-y: scroll;
|
||||
overflow-x: scroll;
|
||||
table-layout: fixed;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
margin: 0px 0px 30px 0px;
|
||||
}
|
||||
.EditorTheme__tokenComment {
|
||||
color: slategray;
|
||||
}
|
||||
.EditorTheme__tokenPunctuation {
|
||||
color: #999;
|
||||
}
|
||||
.EditorTheme__tokenProperty {
|
||||
color: #905;
|
||||
}
|
||||
.EditorTheme__tokenSelector {
|
||||
color: #690;
|
||||
}
|
||||
.EditorTheme__tokenOperator {
|
||||
color: #9a6e3a;
|
||||
}
|
||||
.EditorTheme__tokenAttr {
|
||||
color: #07a;
|
||||
}
|
||||
.EditorTheme__tokenVariable {
|
||||
color: #e90;
|
||||
}
|
||||
.EditorTheme__tokenFunction {
|
||||
color: #dd4a68;
|
||||
}
|
||||
|
||||
.Collapsible__container {
|
||||
background-color: var(--background);
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.Collapsible__title{
|
||||
padding: 0.25rem;
|
||||
padding-left: 1rem;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
list-style-type: disclosure-closed;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.Collapsible__title p{
|
||||
display: inline-flex;
|
||||
}
|
||||
.Collapsible__title::marker{
|
||||
color: lightgray;
|
||||
}
|
||||
.Collapsible__container[open] >.Collapsible__title {
|
||||
list-style-type: disclosure-open;
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { EditorThemeClasses } from "lexical"
|
||||
|
||||
import "./editor-theme.css"
|
||||
|
||||
export const editorTheme: EditorThemeClasses = {
|
||||
ltr: "text-left",
|
||||
rtl: "text-right",
|
||||
heading: {
|
||||
h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
|
||||
h2: "scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0",
|
||||
h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
|
||||
h4: "scroll-m-20 text-xl font-semibold tracking-tight",
|
||||
h5: "scroll-m-20 text-lg font-semibold tracking-tight",
|
||||
h6: "scroll-m-20 text-base font-semibold tracking-tight",
|
||||
},
|
||||
paragraph: "leading-7 [&:not(:first-child)]:mt-6",
|
||||
quote: "mt-6 border-l-2 pl-6 italic",
|
||||
link: "text-blue-600 hover:underline hover:cursor-pointer",
|
||||
list: {
|
||||
checklist: "relative",
|
||||
listitem: "mx-8",
|
||||
listitemChecked:
|
||||
'relative mx-2 px-6 list-none outline-none line-through before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded before:bg-primary before:bg-no-repeat after:content-[""] after:cursor-pointer after:border-white after:border-solid after:absolute after:block after:top-[6px] after:w-[3px] after:left-[7px] after:right-[7px] after:h-[6px] after:rotate-45 after:border-r-2 after:border-b-2 after:border-l-0 after:border-t-0',
|
||||
listitemUnchecked:
|
||||
'relative mx-2 px-6 list-none outline-none before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded',
|
||||
nested: {
|
||||
listitem: "list-none before:hidden after:hidden",
|
||||
},
|
||||
ol: "m-0 p-0 list-decimal [&>li]:mt-2",
|
||||
olDepth: [
|
||||
"list-outside !list-decimal",
|
||||
"list-outside !list-[upper-roman]",
|
||||
"list-outside !list-[lower-roman]",
|
||||
"list-outside !list-[upper-alpha]",
|
||||
"list-outside !list-[lower-alpha]",
|
||||
],
|
||||
ul: "m-0 p-0 list-outside [&>li]:mt-2",
|
||||
ulDepth: [
|
||||
"list-outside !list-disc",
|
||||
"list-outside !list-disc",
|
||||
"list-outside !list-disc",
|
||||
"list-outside !list-disc",
|
||||
"list-outside !list-disc",
|
||||
],
|
||||
},
|
||||
hashtag: "text-blue-600 bg-blue-100 rounded-md px-1",
|
||||
text: {
|
||||
bold: "font-bold",
|
||||
code: "bg-gray-100 p-1 rounded-md",
|
||||
italic: "italic",
|
||||
strikethrough: "line-through",
|
||||
subscript: "sub",
|
||||
superscript: "sup",
|
||||
underline: "underline",
|
||||
underlineStrikethrough: "underline line-through",
|
||||
},
|
||||
image: "relative inline-block user-select-none cursor-default editor-image",
|
||||
inlineImage:
|
||||
"relative inline-block user-select-none cursor-default inline-editor-image",
|
||||
keyword: "text-purple-900 font-bold",
|
||||
code: "EditorTheme__code",
|
||||
codeHighlight: {
|
||||
atrule: "EditorTheme__tokenAttr",
|
||||
attr: "EditorTheme__tokenAttr",
|
||||
boolean: "EditorTheme__tokenProperty",
|
||||
builtin: "EditorTheme__tokenSelector",
|
||||
cdata: "EditorTheme__tokenComment",
|
||||
char: "EditorTheme__tokenSelector",
|
||||
class: "EditorTheme__tokenFunction",
|
||||
"class-name": "EditorTheme__tokenFunction",
|
||||
comment: "EditorTheme__tokenComment",
|
||||
constant: "EditorTheme__tokenProperty",
|
||||
deleted: "EditorTheme__tokenProperty",
|
||||
doctype: "EditorTheme__tokenComment",
|
||||
entity: "EditorTheme__tokenOperator",
|
||||
function: "EditorTheme__tokenFunction",
|
||||
important: "EditorTheme__tokenVariable",
|
||||
inserted: "EditorTheme__tokenSelector",
|
||||
keyword: "EditorTheme__tokenAttr",
|
||||
namespace: "EditorTheme__tokenVariable",
|
||||
number: "EditorTheme__tokenProperty",
|
||||
operator: "EditorTheme__tokenOperator",
|
||||
prolog: "EditorTheme__tokenComment",
|
||||
property: "EditorTheme__tokenProperty",
|
||||
punctuation: "EditorTheme__tokenPunctuation",
|
||||
regex: "EditorTheme__tokenVariable",
|
||||
selector: "EditorTheme__tokenSelector",
|
||||
string: "EditorTheme__tokenSelector",
|
||||
symbol: "EditorTheme__tokenProperty",
|
||||
tag: "EditorTheme__tokenProperty",
|
||||
url: "EditorTheme__tokenOperator",
|
||||
variable: "EditorTheme__tokenVariable",
|
||||
},
|
||||
characterLimit: "!bg-destructive/50",
|
||||
table: "EditorTheme__table w-fit overflow-scroll border-collapse",
|
||||
tableCell:
|
||||
'EditorTheme__tableCell w-24 relative border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"',
|
||||
tableCellActionButton:
|
||||
"EditorTheme__tableCellActionButton bg-background block border-0 rounded-2xl w-5 h-5 text-foreground cursor-pointer",
|
||||
tableCellActionButtonContainer:
|
||||
"EditorTheme__tableCellActionButtonContainer block right-1 top-1.5 absolute z-10 w-5 h-5",
|
||||
tableCellEditing: "EditorTheme__tableCellEditing rounded-sm shadow-sm",
|
||||
tableCellHeader:
|
||||
"EditorTheme__tableCellHeader bg-muted border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right",
|
||||
tableCellPrimarySelected:
|
||||
"EditorTheme__tableCellPrimarySelected border border-primary border-solid block h-[calc(100%-2px)] w-[calc(100%-2px)] absolute -left-[1px] -top-[1px] z-10 ",
|
||||
tableCellResizer:
|
||||
"EditorTheme__tableCellResizer absolute -right-1 h-full w-2 cursor-ew-resize z-10 top-0",
|
||||
tableCellSelected: "EditorTheme__tableCellSelected bg-muted",
|
||||
tableCellSortedIndicator:
|
||||
"EditorTheme__tableCellSortedIndicator block opacity-50 bsolute bottom-0 left-0 w-full h-1 bg-muted",
|
||||
tableResizeRuler:
|
||||
"EditorTheme__tableCellResizeRuler block absolute w-[1px] h-full bg-primary top-0",
|
||||
tableRowStriping:
|
||||
"EditorTheme__tableRowStriping m-0 border-t p-0 even:bg-muted",
|
||||
tableSelected: "EditorTheme__tableSelected ring-2 ring-primary ring-offset-2",
|
||||
tableSelection: "EditorTheme__tableSelection bg-transparent",
|
||||
layoutItem: "border border-dashed px-4 py-2",
|
||||
layoutContainer: "grid gap-2.5 my-2.5 mx-0",
|
||||
autocomplete: "text-muted-foreground",
|
||||
blockCursor: "",
|
||||
embedBlock: {
|
||||
base: "user-select-none",
|
||||
focus: "ring-2 ring-primary ring-offset-2",
|
||||
},
|
||||
hr: 'p-0.5 border-none my-1 mx-0 cursor-pointer after:content-[""] after:block after:h-0.5 after:bg-muted selected:ring-2 selected:ring-primary selected:ring-offset-2 selected:user-select-none',
|
||||
indent: "[--lexical-indent-base-value:40px]",
|
||||
mark: "",
|
||||
markOverlap: "",
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "../ui/pagination";
|
||||
|
||||
interface Props {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export default function HomePagination({ page, totalPages }: Props) {
|
||||
const pagesToShow = 3; // hiển thị tối đa 5 nút
|
||||
let startPage = Math.max(1, page - Math.floor(pagesToShow / 2));
|
||||
let endPage = startPage + pagesToShow - 1;
|
||||
if (endPage > totalPages) {
|
||||
endPage = totalPages;
|
||||
startPage = Math.max(1, endPage - pagesToShow + 1);
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const getPageLink = (p: number) => `${pathname}?page=${p}`;
|
||||
|
||||
const prevPage = page > 1 ? page - 1 : 1;
|
||||
const nextPage = page < totalPages ? page + 1 : totalPages;
|
||||
|
||||
return (
|
||||
<div className="sticky z-50 bottom-0 bg-white w-full flex items-center justify-center py-2 col-span-12 border-t mt-10">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href={getPageLink(prevPage)} />
|
||||
</PaginationItem>
|
||||
|
||||
{startPage > 1 && (
|
||||
<>
|
||||
<PaginationItem>
|
||||
<PaginationLink href={getPageLink(1)}>1</PaginationLink>
|
||||
</PaginationItem>
|
||||
{startPage > 2 && (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pages.map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink href={getPageLink(p)} isActive={p === page}>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
{endPage < totalPages && (
|
||||
<>
|
||||
{endPage < totalPages - 1 && (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationLink href={getPageLink(totalPages)}>
|
||||
{totalPages}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext href={getPageLink(nextPage)} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
import { Sku } from "@/entities/Sku";
|
||||
import axios from "@/lib/axios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../ui/card";
|
||||
import { FormField, FormMessage } from "../ui/form";
|
||||
import { Input } from "../ui/input";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import UncontrolledJoditEditor from "./uncontrolled-jodit-editor";
|
||||
|
||||
export interface IItemProps {
|
||||
data: Pick<
|
||||
Sku,
|
||||
| "id"
|
||||
| "normalized_title"
|
||||
| "normalized_short_description"
|
||||
| "normalized_html"
|
||||
| "sku"
|
||||
| "status"
|
||||
>;
|
||||
}
|
||||
|
||||
const ItemSchema = z.object({
|
||||
normalized_title: z
|
||||
.string()
|
||||
.min(1, "Title must be at least 1 character long"),
|
||||
normalized_short_description: z.string().optional(),
|
||||
status: z.string(),
|
||||
normalized_html: z.string(),
|
||||
});
|
||||
|
||||
type ItemFormValues = z.infer<typeof ItemSchema>;
|
||||
|
||||
export default function Item({ data }: IItemProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<ItemFormValues>({
|
||||
resolver: zodResolver(ItemSchema),
|
||||
defaultValues: {
|
||||
normalized_title: data.normalized_title || "",
|
||||
normalized_short_description: data.normalized_short_description || "",
|
||||
status: data.status,
|
||||
normalized_html: data.normalized_html,
|
||||
},
|
||||
});
|
||||
|
||||
const getNewInfo = async () => {
|
||||
try {
|
||||
const res = await axios.get(`skus/${data.id}`);
|
||||
if (res?.data) {
|
||||
form.reset(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Failed to refresh SKU data");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePass = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.put("skus", {
|
||||
...data,
|
||||
status: "pass",
|
||||
});
|
||||
if (res?.data) {
|
||||
toast.success(res.data?.message || "Marked as Pass");
|
||||
await getNewInfo();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Failed to update status");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: ItemFormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.put("skus", { ...data, ...values });
|
||||
|
||||
if (res?.data) {
|
||||
toast.success(res.data?.message || "Updated successfully");
|
||||
await getNewInfo();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Failed to save data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="relative"
|
||||
id={data.sku}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Card
|
||||
className={cn("w-full h-fit", {
|
||||
["border-green-600"]: form.getValues()?.status === "pass",
|
||||
["border-red-600"]: form.getValues()?.status === "error",
|
||||
})}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{data.sku}</CardTitle>
|
||||
<CardDescription>
|
||||
Status:{" "}
|
||||
<Badge className="capitalize" variant={"outline"}>
|
||||
{form.getValues()?.status}
|
||||
</Badge>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
disabled={form.getValues()?.status === "pass"}
|
||||
onClick={handlePass}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Pass
|
||||
</Button>
|
||||
<Button className="ml-2" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="normalized_title"
|
||||
render={({ field, fieldState }) => (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={field.name}>Title</Label>
|
||||
<Input {...field} placeholder="Some text..." />
|
||||
<FormMessage>{fieldState.error?.message}</FormMessage>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="normalized_short_description"
|
||||
render={({ field, fieldState }) => (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={field.name}>Short Description</Label>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Description..."
|
||||
className="resize-none"
|
||||
/>
|
||||
<FormMessage>{fieldState.error?.message}</FormMessage>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="normalized_html"
|
||||
render={({ field, fieldState }) => (
|
||||
<div className="grid gap-2 overflow-x-auto">
|
||||
<Label htmlFor={field.name}>Description</Label>
|
||||
<UncontrolledJoditEditor field={field} />
|
||||
<FormMessage>{fieldState.error?.message}</FormMessage>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overlay spinner khi loading */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-50 rounded-xl">
|
||||
<Spinner className="h-12 w-12 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sku } from "@/entities/Sku";
|
||||
import { ChevronRight, Search, X } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
|
||||
export default function SKUListSidebar({
|
||||
data,
|
||||
}: {
|
||||
data: Pick<
|
||||
Sku,
|
||||
| "id"
|
||||
| "normalized_title"
|
||||
| "normalized_short_description"
|
||||
| "sku"
|
||||
| "status"
|
||||
>[];
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedSKU, setSelectedSKU] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
startTransition(() => {
|
||||
if (!searchQuery) {
|
||||
router.push(`/`);
|
||||
} else {
|
||||
router.push(`/?search=${encodeURIComponent(searchQuery)}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Nếu URL search thay đổi, update state
|
||||
setSearchQuery(searchParams.get("search") || "");
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="w-full border-r border-border bg-card flex flex-col h-screen">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border p-4">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
Products
|
||||
</h2>
|
||||
|
||||
{/* Search Input */}
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
name="search"
|
||||
placeholder="Search products..."
|
||||
className="pl-10 bg-background border-border"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery?.length > 0 && (
|
||||
<X
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
router.push(`/`);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* SKU List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<nav className="p-2 space-y-1">
|
||||
{data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item.sku}
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // chặn default jump
|
||||
setSelectedSKU(item.sku);
|
||||
const el = document.getElementById(item.sku);
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`group flex items-start gap-3 px-3 py-3 rounded-lg transition-colors cursor-pointer ${
|
||||
selectedSKU === item.sku
|
||||
? "bg-primary/10 border border-primary/20"
|
||||
: "hover:bg-muted border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{/* SKU Badge */}
|
||||
<div className="inline-flex items-center justify-center px-3 py-1 rounded-md bg-primary/20">
|
||||
<span className="text-xs font-semibold text-primary truncate">
|
||||
{item.sku}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{item.normalized_title}
|
||||
</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full whitespace-nowrap capitalize ${
|
||||
item.status === "pass"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400"
|
||||
: "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{item.normalized_short_description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground mt-1 transition-colors" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No products found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<Spinner className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
const JoditEditor = dynamic(() => import("jodit-react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function UncontrolledJoditEditor({ field }: { field: any }) {
|
||||
const editor = useRef(null);
|
||||
const [content, setContent] = useState(field.value || "");
|
||||
|
||||
// Sync lại khi form reset dữ liệu
|
||||
useEffect(() => {
|
||||
setContent(field.value || "");
|
||||
}, [field.value]);
|
||||
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
readonly: false,
|
||||
height: 400,
|
||||
toolbarButtonSize: "middle",
|
||||
uploader: { insertImageAsBase64URI: true },
|
||||
toolbarAdaptive: false,
|
||||
askBeforePasteHTML: false,
|
||||
askBeforePasteFromWord: false,
|
||||
defaultActionOnPaste: "insert_clear_html",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<JoditEditor
|
||||
ref={editor}
|
||||
value={content}
|
||||
config={config as any}
|
||||
onBlur={(newContent) => {
|
||||
setContent(newContent);
|
||||
field.onChange(newContent);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
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 badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
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 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: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity({ name: "skus" })
|
||||
export class Sku {
|
||||
@PrimaryGeneratedColumn("increment")
|
||||
id!: number;
|
||||
|
||||
@Column({ type: "varchar", length: 255, unique: true })
|
||||
sku!: string;
|
||||
|
||||
@Column({
|
||||
type: "enum",
|
||||
enum: ["pass", "done", "error", "pending", "processing"],
|
||||
})
|
||||
status!: string;
|
||||
|
||||
@Column({ type: "json", nullable: true })
|
||||
source_data?: string;
|
||||
|
||||
@Column({ type: "longtext", nullable: true })
|
||||
normalized_html?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true })
|
||||
normalized_title?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true })
|
||||
normalized_short_description?: string;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
error_message?: string;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: "timestamp",
|
||||
precision: 0,
|
||||
default: () => "CURRENT_TIMESTAMP",
|
||||
})
|
||||
created_at!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: "timestamp",
|
||||
precision: 0,
|
||||
default: () => "CURRENT_TIMESTAMP",
|
||||
onUpdate: "CURRENT_TIMESTAMP",
|
||||
})
|
||||
updated_at!: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import ax from "axios";
|
||||
const axios = ax.create({
|
||||
baseURL: `${process.env.NEXT_PUBLIC_API_URL}/api` || "api",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export default axios;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import "reflect-metadata";
|
||||
import { DataSource } from "typeorm";
|
||||
import { Sku } from "../entities/Sku";
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: "mysql",
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT),
|
||||
username: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
synchronize: true, // chỉ dev, tự tạo bảng
|
||||
logging: false,
|
||||
entities: [Sku],
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { AppDataSource } from "./data-source";
|
||||
|
||||
export const initDB = async () => {
|
||||
if (!AppDataSource.isInitialized) {
|
||||
await AppDataSource.initialize();
|
||||
console.log("Database connected");
|
||||
}
|
||||
};
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
|
|
|||
Loading…
Reference in New Issue