doo-boilerplate 0.1.15 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +43 -139
- package/package.json +1 -1
- package/templates/template-vite/_env.example +8 -0
- package/templates/template-vite/package.json +5 -2
- package/templates/template-vite/src/components/data-table/data-table-column-header.tsx +62 -0
- package/templates/template-vite/src/components/data-table/data-table-faceted-filter.tsx +129 -0
- package/templates/template-vite/src/components/data-table/data-table-pagination.tsx +80 -0
- package/templates/template-vite/src/components/data-table/data-table-toolbar.tsx +66 -0
- package/templates/template-vite/src/components/data-table/data-table-view-options.tsx +46 -0
- package/templates/template-vite/src/components/data-table/data-table.tsx +63 -0
- package/templates/template-vite/src/components/layout/sidebar.tsx +2 -1
- package/templates/template-vite/src/components/ui/alert-dialog.tsx +106 -0
- package/templates/template-vite/src/components/ui/command.tsx +118 -0
- package/templates/template-vite/src/components/ui/popover.tsx +28 -0
- package/templates/template-vite/src/components/ui/table.tsx +77 -0
- package/templates/template-vite/src/features/dashboard/components/overview-chart.tsx +61 -0
- package/templates/template-vite/src/features/dashboard/components/recent-activity.tsx +37 -0
- package/templates/template-vite/src/features/dashboard/components/stats-cards.tsx +37 -0
- package/templates/template-vite/src/features/users/components/user-delete-confirmation-dialog.tsx +48 -0
- package/templates/template-vite/src/features/users/components/user-form-dialog.tsx +143 -0
- package/templates/template-vite/src/features/users/components/users-table-columns.tsx +154 -0
- package/templates/template-vite/src/features/users/components/users-table.tsx +143 -0
- package/templates/template-vite/src/features/users/data/users-mock-data.ts +55 -0
- package/templates/template-vite/src/features/users/schemas/user-form-schema.ts +14 -0
- package/templates/template-vite/src/features/users/types/user.ts +12 -0
- package/templates/template-vite/src/lib/sentry.ts +28 -0
- package/templates/template-vite/src/main.tsx +3 -0
- package/templates/template-vite/src/routes/_authenticated/dashboard.tsx +12 -6
- package/templates/template-vite/src/routes/_authenticated/users.tsx +16 -0
- package/templates/template-vite/vite.config.ts +8 -0
- package/templates/template-vite/optional/charts/deps.json +0 -7
- package/templates/template-vite/optional/dark-mode/deps.json +0 -5
- package/templates/template-vite/optional/dnd/deps.json +0 -8
- package/templates/template-vite/optional/editor/deps.json +0 -10
- package/templates/template-vite/optional/i18n/deps.json +0 -7
- package/templates/template-vite/optional/sentry/deps.json +0 -6
package/dist/index.js
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import * as p3 from "@clack/prompts";
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
5
|
+
import pc2 from "picocolors";
|
|
6
|
+
import path4 from "path";
|
|
7
|
+
import fse3 from "fs-extra";
|
|
8
8
|
import { Command } from "commander";
|
|
9
9
|
|
|
10
10
|
// src/prompts.ts
|
|
11
11
|
import * as p from "@clack/prompts";
|
|
12
|
-
import pc from "picocolors";
|
|
13
12
|
|
|
14
13
|
// src/utils/validate.ts
|
|
15
14
|
import validatePkgName from "validate-npm-package-name";
|
|
@@ -27,14 +26,10 @@ function validateProjectName(name) {
|
|
|
27
26
|
// src/prompts.ts
|
|
28
27
|
async function collectOptions(defaults, isTTY2 = true) {
|
|
29
28
|
if (!isTTY2) {
|
|
30
|
-
const valid = ["editor", "charts", "dnd", "sentry"];
|
|
31
29
|
const validPMs2 = ["pnpm", "bun", "yarn"];
|
|
32
30
|
const projectName2 = defaults.projectName ?? "my-portal";
|
|
33
|
-
const framework2 = defaults.framework === "nextjs" || defaults.framework === "vite" ? defaults.framework : "vite";
|
|
34
31
|
const packageManager2 = validPMs2.includes(defaults.pm) ? defaults.pm : "pnpm";
|
|
35
|
-
|
|
36
|
-
const auth2 = defaults.auth === "jwt" || defaults.auth === "oauth" || defaults.auth === "none" ? defaults.auth : "jwt";
|
|
37
|
-
return { projectName: projectName2, framework: framework2, packageManager: packageManager2, features: features2, auth: auth2 };
|
|
32
|
+
return { projectName: projectName2, packageManager: packageManager2 };
|
|
38
33
|
}
|
|
39
34
|
let projectName;
|
|
40
35
|
if (defaults.projectName) {
|
|
@@ -57,23 +52,6 @@ async function collectOptions(defaults, isTTY2 = true) {
|
|
|
57
52
|
}
|
|
58
53
|
projectName = answer;
|
|
59
54
|
}
|
|
60
|
-
let framework;
|
|
61
|
-
if (defaults.framework === "nextjs" || defaults.framework === "vite") {
|
|
62
|
-
framework = defaults.framework;
|
|
63
|
-
} else {
|
|
64
|
-
const answer = await p.select({
|
|
65
|
-
message: "Framework",
|
|
66
|
-
options: [
|
|
67
|
-
{ value: "vite", label: "Vite 8", hint: "SPA \xB7 faster builds \xB7 TanStack Router" },
|
|
68
|
-
{ value: "nextjs", label: "Next.js 16", hint: "SSR \xB7 App Router \xB7 i18n-ready" }
|
|
69
|
-
]
|
|
70
|
-
});
|
|
71
|
-
if (p.isCancel(answer)) {
|
|
72
|
-
p.cancel("Operation cancelled.");
|
|
73
|
-
process.exit(0);
|
|
74
|
-
}
|
|
75
|
-
framework = answer;
|
|
76
|
-
}
|
|
77
55
|
const validPMs = ["pnpm", "bun", "yarn"];
|
|
78
56
|
let packageManager;
|
|
79
57
|
if (validPMs.includes(defaults.pm)) {
|
|
@@ -93,71 +71,17 @@ async function collectOptions(defaults, isTTY2 = true) {
|
|
|
93
71
|
}
|
|
94
72
|
packageManager = answer;
|
|
95
73
|
}
|
|
96
|
-
|
|
97
|
-
if (defaults.features && defaults.features.length > 0) {
|
|
98
|
-
const valid = ["editor", "charts", "dnd", "sentry"];
|
|
99
|
-
features = defaults.features.filter((f) => valid.includes(f));
|
|
100
|
-
} else {
|
|
101
|
-
const answer = await p.multiselect({
|
|
102
|
-
message: "Features " + pc.dim("(space to toggle, enter to confirm)"),
|
|
103
|
-
options: [
|
|
104
|
-
{ value: "editor", label: "Rich text editor", hint: "Tiptap" },
|
|
105
|
-
{ value: "charts", label: "Charts", hint: "ECharts" },
|
|
106
|
-
{ value: "dnd", label: "Drag & drop", hint: "@dnd-kit" },
|
|
107
|
-
{ value: "sentry", label: "Error tracking", hint: "Sentry" }
|
|
108
|
-
],
|
|
109
|
-
required: false
|
|
110
|
-
});
|
|
111
|
-
if (p.isCancel(answer)) {
|
|
112
|
-
p.cancel("Operation cancelled.");
|
|
113
|
-
process.exit(0);
|
|
114
|
-
}
|
|
115
|
-
features = answer;
|
|
116
|
-
}
|
|
117
|
-
let auth;
|
|
118
|
-
if (defaults.auth === "jwt" || defaults.auth === "oauth" || defaults.auth === "none") {
|
|
119
|
-
auth = defaults.auth;
|
|
120
|
-
} else {
|
|
121
|
-
const answer = await p.select({
|
|
122
|
-
message: "Auth pattern",
|
|
123
|
-
options: [
|
|
124
|
-
{ value: "jwt", label: "JWT tokens", hint: "access + refresh token flow" },
|
|
125
|
-
{ value: "oauth", label: "OAuth", hint: "placeholder, configure later" },
|
|
126
|
-
{ value: "none", label: "None", hint: "skip auth setup" }
|
|
127
|
-
]
|
|
128
|
-
});
|
|
129
|
-
if (p.isCancel(answer)) {
|
|
130
|
-
p.cancel("Operation cancelled.");
|
|
131
|
-
process.exit(0);
|
|
132
|
-
}
|
|
133
|
-
auth = answer;
|
|
134
|
-
}
|
|
135
|
-
return { projectName, framework, packageManager, features, auth };
|
|
74
|
+
return { projectName, packageManager };
|
|
136
75
|
}
|
|
137
76
|
|
|
138
77
|
// src/scaffold.ts
|
|
139
|
-
import
|
|
140
|
-
import
|
|
78
|
+
import fse2 from "fs-extra";
|
|
79
|
+
import path2 from "path";
|
|
141
80
|
import { fileURLToPath } from "url";
|
|
142
81
|
|
|
143
|
-
// src/utils/
|
|
82
|
+
// src/utils/template.ts
|
|
144
83
|
import fse from "fs-extra";
|
|
145
84
|
import path from "path";
|
|
146
|
-
async function mergeDepsIntoPackageJson(destDir, deps) {
|
|
147
|
-
const pkgPath = path.join(destDir, "package.json");
|
|
148
|
-
const pkg = await fse.readJson(pkgPath);
|
|
149
|
-
if (deps.dependencies) {
|
|
150
|
-
pkg.dependencies = { ...pkg.dependencies, ...deps.dependencies };
|
|
151
|
-
}
|
|
152
|
-
if (deps.devDependencies) {
|
|
153
|
-
pkg.devDependencies = { ...pkg.devDependencies, ...deps.devDependencies };
|
|
154
|
-
}
|
|
155
|
-
await fse.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// src/utils/template.ts
|
|
159
|
-
import fse2 from "fs-extra";
|
|
160
|
-
import path2 from "path";
|
|
161
85
|
var TEXT_EXTENSIONS = [
|
|
162
86
|
".ts",
|
|
163
87
|
".tsx",
|
|
@@ -173,23 +97,23 @@ var TEXT_EXTENSIONS = [
|
|
|
173
97
|
".txt"
|
|
174
98
|
];
|
|
175
99
|
async function getAllTextFiles(dir) {
|
|
176
|
-
const entries = await
|
|
100
|
+
const entries = await fse.readdir(dir, { withFileTypes: true, recursive: true });
|
|
177
101
|
return entries.filter((e) => {
|
|
178
102
|
if (!e.isFile()) return false;
|
|
179
103
|
if (!TEXT_EXTENSIONS.some((ext) => e.name.endsWith(ext))) return false;
|
|
180
104
|
const parent = e.parentPath ?? e.path ?? dir;
|
|
181
|
-
const fullPath =
|
|
182
|
-
return !fullPath.includes("node_modules") && !fullPath.includes(`${
|
|
105
|
+
const fullPath = path.join(parent, e.name);
|
|
106
|
+
return !fullPath.includes("node_modules") && !fullPath.includes(`${path.sep}optional${path.sep}`);
|
|
183
107
|
}).map((e) => {
|
|
184
108
|
const parent = e.parentPath ?? e.path ?? dir;
|
|
185
|
-
return
|
|
109
|
+
return path.join(parent, e.name);
|
|
186
110
|
});
|
|
187
111
|
}
|
|
188
112
|
async function replaceInFiles(dir, replacements) {
|
|
189
113
|
const files = await getAllTextFiles(dir);
|
|
190
114
|
await Promise.all(
|
|
191
115
|
files.map(async (file) => {
|
|
192
|
-
let content = await
|
|
116
|
+
let content = await fse.readFile(file, "utf-8");
|
|
193
117
|
let changed = false;
|
|
194
118
|
for (const [from, to] of Object.entries(replacements)) {
|
|
195
119
|
if (content.includes(from)) {
|
|
@@ -198,14 +122,14 @@ async function replaceInFiles(dir, replacements) {
|
|
|
198
122
|
}
|
|
199
123
|
}
|
|
200
124
|
if (changed) {
|
|
201
|
-
await
|
|
125
|
+
await fse.writeFile(file, content, "utf-8");
|
|
202
126
|
}
|
|
203
127
|
})
|
|
204
128
|
);
|
|
205
129
|
}
|
|
206
130
|
|
|
207
131
|
// src/scaffold.ts
|
|
208
|
-
var __dirname2 =
|
|
132
|
+
var __dirname2 = path2.dirname(fileURLToPath(import.meta.url));
|
|
209
133
|
var FILE_RENAMES = [
|
|
210
134
|
["_gitignore", ".gitignore"],
|
|
211
135
|
["_env.example", ".env.example"],
|
|
@@ -213,41 +137,24 @@ var FILE_RENAMES = [
|
|
|
213
137
|
["_prettierignore", ".prettierignore"]
|
|
214
138
|
];
|
|
215
139
|
async function scaffold(options, destDir) {
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
await fse3.copy(templateDir, destDir, {
|
|
140
|
+
const templateSymlink = path2.join(__dirname2, "..", "templates", "template-vite");
|
|
141
|
+
const templateDir = await fse2.realpath(templateSymlink);
|
|
142
|
+
await fse2.copy(templateDir, destDir, {
|
|
220
143
|
overwrite: true,
|
|
221
|
-
filter: (src) => !src.includes(`${
|
|
144
|
+
filter: (src) => !src.includes(`${path2.sep}optional${path2.sep}`) && src !== path2.join(templateDir, "optional")
|
|
222
145
|
});
|
|
223
146
|
for (const [from, to] of FILE_RENAMES) {
|
|
224
|
-
const fromPath =
|
|
225
|
-
const toPath =
|
|
226
|
-
if (await
|
|
227
|
-
await
|
|
147
|
+
const fromPath = path2.join(destDir, from);
|
|
148
|
+
const toPath = path2.join(destDir, to);
|
|
149
|
+
if (await fse2.pathExists(fromPath)) {
|
|
150
|
+
await fse2.move(fromPath, toPath);
|
|
228
151
|
}
|
|
229
152
|
}
|
|
230
|
-
for (const feature of options.features) {
|
|
231
|
-
await applyFeature(feature, templateDir, destDir);
|
|
232
|
-
}
|
|
233
153
|
await replaceInFiles(destDir, {
|
|
234
154
|
"{{PROJECT_NAME}}": options.projectName,
|
|
235
155
|
"{{YEAR}}": (/* @__PURE__ */ new Date()).getFullYear().toString()
|
|
236
156
|
});
|
|
237
157
|
}
|
|
238
|
-
async function applyFeature(feature, templateDir, destDir) {
|
|
239
|
-
const featureDir = path3.join(templateDir, "optional", feature);
|
|
240
|
-
if (!await fse3.pathExists(featureDir)) return;
|
|
241
|
-
const filesDir = path3.join(featureDir, "files");
|
|
242
|
-
if (await fse3.pathExists(filesDir)) {
|
|
243
|
-
await fse3.copy(filesDir, path3.join(destDir, "src"), { overwrite: true });
|
|
244
|
-
}
|
|
245
|
-
const depsFile = path3.join(featureDir, "deps.json");
|
|
246
|
-
if (await fse3.pathExists(depsFile)) {
|
|
247
|
-
const deps = await fse3.readJson(depsFile);
|
|
248
|
-
await mergeDepsIntoPackageJson(destDir, deps);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
158
|
|
|
252
159
|
// src/git.ts
|
|
253
160
|
import { execa } from "execa";
|
|
@@ -261,20 +168,20 @@ async function initGit(destDir) {
|
|
|
261
168
|
|
|
262
169
|
// src/post-setup.ts
|
|
263
170
|
import * as p2 from "@clack/prompts";
|
|
264
|
-
import
|
|
265
|
-
import
|
|
171
|
+
import pc from "picocolors";
|
|
172
|
+
import path3 from "path";
|
|
266
173
|
function printSuccess(options, destDir, isTTY2 = true) {
|
|
267
|
-
const relPath =
|
|
174
|
+
const relPath = path3.relative(process.cwd(), destDir);
|
|
268
175
|
const cdTarget = relPath || options.projectName;
|
|
269
176
|
const pm = options.packageManager;
|
|
270
|
-
const runCmd = `${pm}
|
|
271
|
-
const msg = `${
|
|
177
|
+
const runCmd = `${pm} dev`;
|
|
178
|
+
const msg = `${pc.green("\u2713")} Project created! Get started:
|
|
272
179
|
|
|
273
|
-
${
|
|
274
|
-
${
|
|
275
|
-
${
|
|
180
|
+
${pc.cyan(`cd ${cdTarget}`)}
|
|
181
|
+
${pc.cyan(`${pm} install`)}
|
|
182
|
+
${pc.cyan(runCmd)}
|
|
276
183
|
|
|
277
|
-
${
|
|
184
|
+
${pc.dim("Copy .env.example \u2192 .env and fill in your API URLs.")}`;
|
|
278
185
|
if (isTTY2) {
|
|
279
186
|
p2.outro(msg);
|
|
280
187
|
} else {
|
|
@@ -288,34 +195,31 @@ function log(msg) {
|
|
|
288
195
|
console.log(msg);
|
|
289
196
|
}
|
|
290
197
|
async function run() {
|
|
291
|
-
const program = new Command().name("create-pila-app").description("Scaffold a Pila portal frontend project").version("0.1.0").argument("[project-name]", "Name of the project").option("--
|
|
198
|
+
const program = new Command().name("create-pila-app").description("Scaffold a Pila portal frontend project").version("0.1.0").argument("[project-name]", "Name of the project").option("--pm <pm>", "Package manager: pnpm, bun, or yarn").option("--no-git", "Skip git initialization").parse(process.argv);
|
|
292
199
|
log("");
|
|
293
200
|
if (isTTY) {
|
|
294
|
-
p3.intro(
|
|
201
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-pila-app ")));
|
|
295
202
|
} else {
|
|
296
|
-
log(
|
|
203
|
+
log(pc2.bgCyan(pc2.black(" create-pila-app ")));
|
|
297
204
|
}
|
|
298
205
|
const args = program.args;
|
|
299
206
|
const opts = program.opts();
|
|
300
207
|
const options = await collectOptions({
|
|
301
208
|
projectName: args[0],
|
|
302
|
-
|
|
303
|
-
pm: opts.pm,
|
|
304
|
-
features: opts.features?.split(","),
|
|
305
|
-
auth: opts.auth
|
|
209
|
+
pm: opts.pm
|
|
306
210
|
}, isTTY);
|
|
307
|
-
const destDir =
|
|
308
|
-
if (await
|
|
309
|
-
const files = await
|
|
211
|
+
const destDir = path4.resolve(process.cwd(), options.projectName);
|
|
212
|
+
if (await fse3.pathExists(destDir)) {
|
|
213
|
+
const files = await fse3.readdir(destDir);
|
|
310
214
|
if (files.length > 0) {
|
|
311
215
|
const msg = `Directory "${options.projectName}" already exists and is not empty.`;
|
|
312
216
|
if (isTTY) p3.cancel(msg);
|
|
313
|
-
else log(
|
|
217
|
+
else log(pc2.red("\u2716 " + msg));
|
|
314
218
|
process.exit(1);
|
|
315
219
|
}
|
|
316
220
|
}
|
|
317
|
-
await
|
|
318
|
-
log(
|
|
221
|
+
await fse3.ensureDir(destDir);
|
|
222
|
+
log(pc2.cyan("\u2192 Scaffolding project..."));
|
|
319
223
|
if (isTTY) {
|
|
320
224
|
const scaffoldSpinner = p3.spinner();
|
|
321
225
|
scaffoldSpinner.start("Scaffolding project...");
|
|
@@ -328,7 +232,7 @@ async function run() {
|
|
|
328
232
|
}
|
|
329
233
|
} else {
|
|
330
234
|
await scaffold(options, destDir);
|
|
331
|
-
log(
|
|
235
|
+
log(pc2.green("\u2713 Project scaffolded"));
|
|
332
236
|
}
|
|
333
237
|
if (opts.git !== false) {
|
|
334
238
|
try {
|
package/package.json
CHANGED
|
@@ -6,3 +6,11 @@ VITE_API_AUTH_URL=http://localhost:8001
|
|
|
6
6
|
VITE_APP_URL=http://localhost:3000
|
|
7
7
|
VITE_APP_NAME={{PROJECT_NAME}}
|
|
8
8
|
VITE_MODE=development
|
|
9
|
+
|
|
10
|
+
# Sentry Error Tracking (optional)
|
|
11
|
+
VITE_SENTRY_DSN=
|
|
12
|
+
|
|
13
|
+
# Sentry Source Maps Upload (build-time only, optional)
|
|
14
|
+
SENTRY_AUTH_TOKEN=
|
|
15
|
+
SENTRY_ORG=
|
|
16
|
+
SENTRY_PROJECT=
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"docker:scan": "bash scripts/trivy-scan.sh"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"@sentry/react": "^9.0.0",
|
|
22
23
|
"react": "^19.2.0",
|
|
23
24
|
"react-dom": "^19.2.0",
|
|
24
25
|
"i18next": "^25.2.1",
|
|
@@ -56,7 +57,8 @@
|
|
|
56
57
|
"@radix-ui/react-switch": "^1.1.3",
|
|
57
58
|
"@radix-ui/react-tabs": "^1.1.3",
|
|
58
59
|
"@radix-ui/react-tooltip": "^1.1.8",
|
|
59
|
-
"cmdk": "^1.1.1"
|
|
60
|
+
"cmdk": "^1.1.1",
|
|
61
|
+
"recharts": "^2.15.0"
|
|
60
62
|
},
|
|
61
63
|
"devDependencies": {
|
|
62
64
|
"@types/react": "^19",
|
|
@@ -83,7 +85,8 @@
|
|
|
83
85
|
"husky": "^9.1.7",
|
|
84
86
|
"lint-staged": "^15.2.11",
|
|
85
87
|
"knip": "^5.64.2",
|
|
86
|
-
"swagger-typescript-api": "^13.2.7"
|
|
88
|
+
"swagger-typescript-api": "^13.2.7",
|
|
89
|
+
"@sentry/vite-plugin": "^3.0.0"
|
|
87
90
|
},
|
|
88
91
|
"lint-staged": {
|
|
89
92
|
"*.{js,ts,tsx,css}": ["eslint --fix", "prettier --write"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { type Column } from '@tanstack/react-table'
|
|
3
|
+
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from '@/components/ui/dropdown-menu'
|
|
14
|
+
|
|
15
|
+
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
|
16
|
+
column: Column<TData, TValue>
|
|
17
|
+
title: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function DataTableColumnHeader<TData, TValue>({
|
|
21
|
+
column,
|
|
22
|
+
title,
|
|
23
|
+
className,
|
|
24
|
+
}: DataTableColumnHeaderProps<TData, TValue>) {
|
|
25
|
+
if (!column.getCanSort()) {
|
|
26
|
+
return <div className={cn(className)}>{title}</div>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn('flex items-center space-x-2', className)}>
|
|
31
|
+
<DropdownMenu>
|
|
32
|
+
<DropdownMenuTrigger asChild>
|
|
33
|
+
<Button variant='ghost' size='sm' className='-ml-3 h-8 data-[state=open]:bg-accent'>
|
|
34
|
+
<span>{title}</span>
|
|
35
|
+
{column.getIsSorted() === 'desc' ? (
|
|
36
|
+
<ArrowDown className='ml-2 h-4 w-4' />
|
|
37
|
+
) : column.getIsSorted() === 'asc' ? (
|
|
38
|
+
<ArrowUp className='ml-2 h-4 w-4' />
|
|
39
|
+
) : (
|
|
40
|
+
<ChevronsUpDown className='ml-2 h-4 w-4' />
|
|
41
|
+
)}
|
|
42
|
+
</Button>
|
|
43
|
+
</DropdownMenuTrigger>
|
|
44
|
+
<DropdownMenuContent align='start'>
|
|
45
|
+
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
|
46
|
+
<ArrowUp className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
|
47
|
+
Asc
|
|
48
|
+
</DropdownMenuItem>
|
|
49
|
+
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
|
50
|
+
<ArrowDown className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
|
51
|
+
Desc
|
|
52
|
+
</DropdownMenuItem>
|
|
53
|
+
<DropdownMenuSeparator />
|
|
54
|
+
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
|
55
|
+
<EyeOff className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
|
56
|
+
Hide
|
|
57
|
+
</DropdownMenuItem>
|
|
58
|
+
</DropdownMenuContent>
|
|
59
|
+
</DropdownMenu>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { type Column } from '@tanstack/react-table'
|
|
3
|
+
import { Check, PlusCircle } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import { Badge } from '@/components/ui/badge'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import {
|
|
9
|
+
Command,
|
|
10
|
+
CommandEmpty,
|
|
11
|
+
CommandGroup,
|
|
12
|
+
CommandInput,
|
|
13
|
+
CommandItem,
|
|
14
|
+
CommandList,
|
|
15
|
+
CommandSeparator,
|
|
16
|
+
} from '@/components/ui/command'
|
|
17
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
18
|
+
import { Separator } from '@/components/ui/separator'
|
|
19
|
+
|
|
20
|
+
interface FacetedFilterOption {
|
|
21
|
+
label: string
|
|
22
|
+
value: string
|
|
23
|
+
icon?: React.ComponentType<{ className?: string }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DataTableFacetedFilterProps<TData, TValue> {
|
|
27
|
+
column?: Column<TData, TValue>
|
|
28
|
+
title?: string
|
|
29
|
+
options: FacetedFilterOption[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function DataTableFacetedFilter<TData, TValue>({
|
|
33
|
+
column,
|
|
34
|
+
title,
|
|
35
|
+
options,
|
|
36
|
+
}: DataTableFacetedFilterProps<TData, TValue>) {
|
|
37
|
+
const facets = column?.getFacetedUniqueValues()
|
|
38
|
+
const selectedValues = new Set(column?.getFilterValue() as string[])
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Popover>
|
|
42
|
+
<PopoverTrigger asChild>
|
|
43
|
+
<Button variant='outline' size='sm' className='h-8 border-dashed'>
|
|
44
|
+
<PlusCircle className='mr-2 h-4 w-4' />
|
|
45
|
+
{title}
|
|
46
|
+
{selectedValues.size > 0 && (
|
|
47
|
+
<>
|
|
48
|
+
<Separator orientation='vertical' className='mx-2 h-4' />
|
|
49
|
+
<Badge variant='secondary' className='rounded-sm px-1 font-normal lg:hidden'>
|
|
50
|
+
{selectedValues.size}
|
|
51
|
+
</Badge>
|
|
52
|
+
<div className='hidden space-x-1 lg:flex'>
|
|
53
|
+
{selectedValues.size > 2 ? (
|
|
54
|
+
<Badge variant='secondary' className='rounded-sm px-1 font-normal'>
|
|
55
|
+
{selectedValues.size} selected
|
|
56
|
+
</Badge>
|
|
57
|
+
) : (
|
|
58
|
+
options
|
|
59
|
+
.filter((option) => selectedValues.has(option.value))
|
|
60
|
+
.map((option) => (
|
|
61
|
+
<Badge key={option.value} variant='secondary' className='rounded-sm px-1 font-normal'>
|
|
62
|
+
{option.label}
|
|
63
|
+
</Badge>
|
|
64
|
+
))
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</>
|
|
68
|
+
)}
|
|
69
|
+
</Button>
|
|
70
|
+
</PopoverTrigger>
|
|
71
|
+
<PopoverContent className='w-[200px] p-0' align='start'>
|
|
72
|
+
<Command>
|
|
73
|
+
<CommandInput placeholder={title} />
|
|
74
|
+
<CommandList>
|
|
75
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
76
|
+
<CommandGroup>
|
|
77
|
+
{options.map((option) => {
|
|
78
|
+
const isSelected = selectedValues.has(option.value)
|
|
79
|
+
return (
|
|
80
|
+
<CommandItem
|
|
81
|
+
key={option.value}
|
|
82
|
+
onSelect={() => {
|
|
83
|
+
if (isSelected) {
|
|
84
|
+
selectedValues.delete(option.value)
|
|
85
|
+
} else {
|
|
86
|
+
selectedValues.add(option.value)
|
|
87
|
+
}
|
|
88
|
+
const filterValues = Array.from(selectedValues)
|
|
89
|
+
column?.setFilterValue(filterValues.length ? filterValues : undefined)
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<div
|
|
93
|
+
className={cn(
|
|
94
|
+
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
|
95
|
+
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible'
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<Check className='h-4 w-4' />
|
|
99
|
+
</div>
|
|
100
|
+
{option.icon && <option.icon className='mr-2 h-4 w-4 text-muted-foreground' />}
|
|
101
|
+
<span>{option.label}</span>
|
|
102
|
+
{facets?.get(option.value) && (
|
|
103
|
+
<span className='ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
|
104
|
+
{facets.get(option.value)}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
</CommandItem>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</CommandGroup>
|
|
111
|
+
{selectedValues.size > 0 && (
|
|
112
|
+
<>
|
|
113
|
+
<CommandSeparator />
|
|
114
|
+
<CommandGroup>
|
|
115
|
+
<CommandItem
|
|
116
|
+
onSelect={() => column?.setFilterValue(undefined)}
|
|
117
|
+
className='justify-center text-center'
|
|
118
|
+
>
|
|
119
|
+
Clear filters
|
|
120
|
+
</CommandItem>
|
|
121
|
+
</CommandGroup>
|
|
122
|
+
</>
|
|
123
|
+
)}
|
|
124
|
+
</CommandList>
|
|
125
|
+
</Command>
|
|
126
|
+
</PopoverContent>
|
|
127
|
+
</Popover>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type Table } from '@tanstack/react-table'
|
|
2
|
+
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
6
|
+
|
|
7
|
+
interface DataTablePaginationProps<TData> {
|
|
8
|
+
table: Table<TData>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
|
12
|
+
return (
|
|
13
|
+
<div className='flex items-center justify-between px-2'>
|
|
14
|
+
<div className='flex-1 text-sm text-muted-foreground'>
|
|
15
|
+
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
|
|
16
|
+
</div>
|
|
17
|
+
<div className='flex items-center space-x-6 lg:space-x-8'>
|
|
18
|
+
<div className='flex items-center space-x-2'>
|
|
19
|
+
<p className='text-sm font-medium'>Rows per page</p>
|
|
20
|
+
<Select
|
|
21
|
+
value={`${table.getState().pagination.pageSize}`}
|
|
22
|
+
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
23
|
+
>
|
|
24
|
+
<SelectTrigger className='h-8 w-[70px]'>
|
|
25
|
+
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
|
26
|
+
</SelectTrigger>
|
|
27
|
+
<SelectContent side='top'>
|
|
28
|
+
{[10, 20, 30, 40, 50].map((pageSize) => (
|
|
29
|
+
<SelectItem key={pageSize} value={`${pageSize}`}>
|
|
30
|
+
{pageSize}
|
|
31
|
+
</SelectItem>
|
|
32
|
+
))}
|
|
33
|
+
</SelectContent>
|
|
34
|
+
</Select>
|
|
35
|
+
</div>
|
|
36
|
+
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
|
|
37
|
+
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
|
38
|
+
</div>
|
|
39
|
+
<div className='flex items-center space-x-2'>
|
|
40
|
+
<Button
|
|
41
|
+
variant='outline'
|
|
42
|
+
className='hidden h-8 w-8 p-0 lg:flex'
|
|
43
|
+
onClick={() => table.setPageIndex(0)}
|
|
44
|
+
disabled={!table.getCanPreviousPage()}
|
|
45
|
+
>
|
|
46
|
+
<span className='sr-only'>Go to first page</span>
|
|
47
|
+
<ChevronsLeft className='h-4 w-4' />
|
|
48
|
+
</Button>
|
|
49
|
+
<Button
|
|
50
|
+
variant='outline'
|
|
51
|
+
className='h-8 w-8 p-0'
|
|
52
|
+
onClick={() => table.previousPage()}
|
|
53
|
+
disabled={!table.getCanPreviousPage()}
|
|
54
|
+
>
|
|
55
|
+
<span className='sr-only'>Go to previous page</span>
|
|
56
|
+
<ChevronLeft className='h-4 w-4' />
|
|
57
|
+
</Button>
|
|
58
|
+
<Button
|
|
59
|
+
variant='outline'
|
|
60
|
+
className='h-8 w-8 p-0'
|
|
61
|
+
onClick={() => table.nextPage()}
|
|
62
|
+
disabled={!table.getCanNextPage()}
|
|
63
|
+
>
|
|
64
|
+
<span className='sr-only'>Go to next page</span>
|
|
65
|
+
<ChevronRight className='h-4 w-4' />
|
|
66
|
+
</Button>
|
|
67
|
+
<Button
|
|
68
|
+
variant='outline'
|
|
69
|
+
className='hidden h-8 w-8 p-0 lg:flex'
|
|
70
|
+
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
|
71
|
+
disabled={!table.getCanNextPage()}
|
|
72
|
+
>
|
|
73
|
+
<span className='sr-only'>Go to last page</span>
|
|
74
|
+
<ChevronsRight className='h-4 w-4' />
|
|
75
|
+
</Button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|