Init Commit
quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 7s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Failing after 38s
quality / api (push) Successful in 49s
CD / quality (push) Failing after 1m36s
CD / publish (push) Skipped

This commit is contained in:
Denozordec
2026-09-04 11:48:19 +07:00
commit cb8a79260e
300 changed files with 42404 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export { }
+417
View File
@@ -0,0 +1,417 @@
// src/server.ts
import { readFileSync, existsSync } from "fs";
import { resolve as resolve3 } from "path";
// src/app.ts
import { resolve as resolve2 } from "path";
import Fastify from "fastify";
import {
serializerCompiler,
validatorCompiler
} from "@fastify/type-provider-zod";
// src/config.ts
import { resolve } from "path";
function boolEnv(v, fallback) {
if (v === void 0 || v === "") return fallback;
return v === "1" || v.toLowerCase() === "true";
}
function loadConfig() {
const isProd = process.env.NODE_ENV === "production";
const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? (isProd ? "" : "dev-secret-change-me");
return {
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
jwtSecret: jwtSecret || "dev-secret-change-me",
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
adminPasswordHash: process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder",
serverPort: Number(process.env.SERVER_PORT ?? "8081") || 8081,
staticDir: process.env.STATIC_DIR ? resolve(process.env.STATIC_DIR) : null,
logLevel: process.env.LOG_LEVEL ?? "info",
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
authIssuer: process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
authPortalUrl: (process.env.AUTH_PORTAL_URL ?? process.env.VITE_AUTH_PORTAL_URL ?? "http://localhost:5175").replace(/\/$/, ""),
authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null)
};
}
// src/plugins/auth.ts
import fp from "fastify-plugin";
// src/errors.ts
import { NotFoundError, ConflictError } from "@cdnmanager/db";
import { ValidationError } from "@cdnmanager/shared";
var AppError = class _AppError extends Error {
constructor(code, message, statusCode) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.name = "AppError";
}
code;
statusCode;
static notFound(message) {
return new _AppError("NOT_FOUND", message, 404);
}
static validation(message) {
return new _AppError("VALIDATION_ERROR", message, 400);
}
static unauthorized() {
return new _AppError("UNAUTHORIZED", "unauthorized", 401);
}
static forbidden(message = "forbidden") {
return new _AppError("FORBIDDEN", message, 403);
}
static conflict(message) {
return new _AppError("CONFLICT", message, 409);
}
static cloudflare(message) {
return new _AppError("CLOUDFLARE_ERROR", message, 502);
}
static dnsUpdateFailed(message) {
return new _AppError(
"DNS_UPDATE_FAILED",
message,
502
);
}
static healthcheckCreateFailed(message) {
return new _AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
}
static zoneNotFound(message = "\u0437\u043E\u043D\u0430 Cloudflare \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430") {
return new _AppError("ZONE_NOT_FOUND", message, 404);
}
static invalidIp(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441") {
return new _AppError("INVALID_IP", message, 400);
}
static invalidHostname(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0438\u043C\u044F \u0445\u043E\u0441\u0442\u0430") {
return new _AppError("INVALID_HOSTNAME", message, 400);
}
static rateLimited(message = "Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443.") {
return new _AppError("RATE_LIMITED", message, 429);
}
static cloudflareAuthFailed(message = "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D \u0434\u043E\u0441\u0442\u0443\u043F\u0430") {
return new _AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
}
static syncFailed(message) {
return new _AppError("SYNC_FAILED", message, 502);
}
static internal(message) {
return new _AppError("INTERNAL_ERROR", message, 500);
}
};
function toAppError(err) {
if (err instanceof AppError) return err;
if (err instanceof NotFoundError) return AppError.notFound(err.message);
if (err instanceof ConflictError) return AppError.conflict(err.message);
if (err instanceof ValidationError) return AppError.validation(err.message);
if (err instanceof Error) return AppError.internal(err.message);
return AppError.internal(String(err));
}
function errorBody(err) {
return {
error: {
code: err.code,
message: err.message
}
};
}
// src/lib/permissions.ts
function hasPermission(granted, required) {
if (granted.includes(required)) return true;
const parts = required.split(":");
if (parts.length !== 3) return false;
const [app2, section, action] = parts;
if (action === "read") {
return granted.includes(`${app2}:${section}:write`) || granted.includes(`${app2}:${section}:admin`);
}
if (action === "write") {
return granted.includes(`${app2}:${section}:admin`);
}
return false;
}
var RULES = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/v1/settings"),
permission: "cdn:settings:admin"
}
];
function permissionForRequest(method, path) {
const m = method.toUpperCase();
const pathname = path.split("?")[0] ?? path;
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue;
if (rule.match(pathname)) return rule.permission;
}
if (pathname.startsWith("/api/v1/")) return "cdn:dashboard:read";
return null;
}
// src/plugins/auth.ts
async function authPlugin(app2, opts) {
const { config: config2 } = opts;
if (config2.authRequired && (!config2.jwtSecret || config2.jwtSecret.length < 8)) {
throw new Error(
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true"
);
}
await app2.register(import("@fastify/jwt"), {
secret: config2.jwtSecret,
...config2.authRequired ? {
verify: {
allowedIss: [config2.authIssuer]
}
} : {}
});
app2.decorate("config", config2);
if (config2.authRequired) {
app2.log.info(
{ issuer: config2.authIssuer, portal: config2.authPortalUrl },
"AUTH_REQUIRED=true \u2014 portal JWT middleware enabled"
);
} else {
app2.log.info("AUTH_REQUIRED=false \u2014 local JWT / open protected routes with requireAuth");
}
}
async function requireAuth(request, reply) {
const config2 = request.server.config;
const authHeader = request.headers.authorization ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
if (!token) throw AppError.unauthorized();
try {
await request.jwtVerify();
} catch {
throw AppError.unauthorized();
}
if (!config2.authRequired) {
return;
}
const payload = request.user;
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : [];
if (!apps.includes("cdn")) {
throw AppError.forbidden("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044E CDN Manager");
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps,
permissions,
isAdmin: Boolean(payload.is_admin)
};
const required = permissionForRequest(request.method, request.url);
if (required && !hasPermission(permissions, required)) {
throw AppError.forbidden(`\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432: ${required}`);
}
}
var auth_default = fp(authPlugin, { name: "auth" });
// src/plugins/cors.ts
import fp2 from "fastify-plugin";
async function corsPlugin(app2) {
await app2.register(import("@fastify/cors"), {
origin: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"]
});
}
var cors_default = fp2(corsPlugin, { name: "cors" });
// src/plugins/db.ts
import fp3 from "fastify-plugin";
import {
createDb,
createMemoryDb,
healthCheck,
runMigrations
} from "@cdnmanager/db";
async function dbPlugin(app2, opts) {
const { db, sqlite } = opts.memory ? createMemoryDb() : createDb(opts.config.databaseUrl);
runMigrations(sqlite);
app2.decorate("db", db);
app2.decorate("sqlite", sqlite);
app2.addHook("onClose", async () => {
sqlite.close();
});
}
var db_default = fp3(dbPlugin, { name: "db" });
// src/plugins/error-handler.ts
import fp4 from "fastify-plugin";
async function errorHandlerPlugin(app2) {
app2.setErrorHandler((err, _request, reply) => {
if (reply.sent) return;
const appErr = err.statusCode === 401 ? AppError.unauthorized() : toAppError(err);
reply.status(appErr.statusCode).send(errorBody(appErr));
});
}
var error_handler_default = fp4(errorHandlerPlugin, { name: "error-handler" });
// src/routes/health.ts
import { z } from "zod";
// src/services/auth.ts
import { verify } from "@node-rs/argon2";
async function verifyPassword(config2, password) {
if (config2.adminPasswordHash === "devplaceholder") {
if (password === "admin") return;
throw AppError.unauthorized();
}
const ok = await verify(config2.adminPasswordHash, password);
if (!ok) throw AppError.unauthorized();
}
async function login(config2, sign, req) {
if (req.username !== config2.adminUsername) {
throw AppError.unauthorized();
}
await verifyPassword(config2, req.password);
const expiresAt = new Date(
Date.now() + config2.jwtTtlHours * 60 * 60 * 1e3
);
const token = sign({
sub: req.username,
exp: Math.floor(expiresAt.getTime() / 1e3)
});
return {
token,
expires_at: expiresAt.toISOString()
};
}
// src/routes/health.ts
async function healthRoutes(app2) {
app2.get("/health", async (request) => {
healthCheck(request.server.sqlite);
return { status: "ok" };
});
app2.get("/ready", async (request) => {
healthCheck(request.server.sqlite);
return {
status: "ready",
database: true
};
});
}
async function authRoutes(app2) {
app2.get("/auth/config", async (request) => {
const { config: config2 } = request.server;
return {
required: config2.authRequired,
portal_url: config2.authPortalUrl
};
});
const loginSchema = z.object({
username: z.string(),
password: z.string()
});
app2.post("/auth/login", async (request, reply) => {
if (request.server.config.authRequired) {
return reply.code(403).send({
error: {
code: "FORBIDDEN",
message: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 auth-portal"
}
});
}
const body = loginSchema.parse(request.body);
const result = await login(
request.server.config,
(payload) => request.server.jwt.sign(payload),
body
);
return result;
});
}
// src/routes/settings.ts
import { appSettingsPatchSchema } from "@cdnmanager/shared";
import { getAppSettings, updateAppSettings } from "@cdnmanager/db";
async function settingsRoutes(app2) {
app2.get("/settings", async (request) => {
return getAppSettings(request.server.db);
});
app2.patch("/settings", async (request) => {
const parsed = appSettingsPatchSchema.safeParse(request.body);
if (!parsed.success) {
throw AppError.validation(
parsed.error.issues[0]?.message ?? "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438"
);
}
return updateAppSettings(request.server.db, parsed.data);
});
}
// src/app.ts
async function buildApp(opts = {}) {
const config2 = opts.config ?? loadConfig();
const app2 = Fastify({
logger: { level: config2.logLevel }
}).withTypeProvider();
app2.setValidatorCompiler(validatorCompiler);
app2.setSerializerCompiler(serializerCompiler);
await app2.register(import("@fastify/sensible"));
await app2.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
await app2.register(import("@fastify/rate-limit"), {
max: 300,
timeWindow: "1 minute"
});
await app2.register(cors_default);
await app2.register(error_handler_default);
await app2.register(db_default, { config: config2, memory: opts.memory });
await app2.register(auth_default, { config: config2 });
await app2.register(healthRoutes);
await app2.register(authRoutes, { prefix: "/api/v1" });
await app2.register(
async (protectedApi) => {
protectedApi.addHook("onRequest", requireAuth);
await protectedApi.register(settingsRoutes);
},
{ prefix: "/api/v1" }
);
const staticDir = config2.staticDir ?? resolve2(process.cwd(), "static");
if (config2.staticDir !== null) {
await app2.register(import("@fastify/static"), {
root: staticDir,
wildcard: false
});
app2.setNotFoundHandler(async (_request, reply) => {
return reply.sendFile("index.html");
});
}
return app2;
}
// src/server.ts
for (const path of [
resolve3(import.meta.dirname, "../../../.env"),
".env",
"../.env"
]) {
if (!existsSync(path)) continue;
const content = readFileSync(path, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
break;
}
var config = loadConfig();
var app = await buildApp({ config });
try {
await app.listen({ port: config.serverPort, host: "0.0.0.0" });
app.log.info(`listening on ${config.serverPort}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@cdnmanager/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup --config tsup.config.ts",
"start": "node dist/server.js",
"test": "vitest run"
},
"dependencies": {
"@cdnmanager/db": "workspace:*",
"@cdnmanager/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/schedule": "^6.0.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"@node-rs/argon2": "^2.0.2",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"toad-scheduler": "^4.0.1",
"undici": "^8.5.0",
"zod": "^4.2.0"
},
"devDependencies": {
"@types/node": "^22.15.32",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
+68
View File
@@ -0,0 +1,68 @@
import { resolve } from "node:path";
import Fastify from "fastify";
import {
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js";
import authPlugin from "./plugins/auth.js";
import { requireAuth } from "./plugins/auth.js";
import corsPlugin from "./plugins/cors.js";
import dbPlugin from "./plugins/db.js";
import errorHandlerPlugin from "./plugins/error-handler.js";
import { authRoutes, healthRoutes } from "./routes/health.js";
import { settingsRoutes } from "./routes/settings.js";
export interface BuildAppOptions {
config?: AppConfig;
memory?: boolean;
}
export async function buildApp(opts: BuildAppOptions = {}) {
const config = opts.config ?? loadConfig();
const app = Fastify({
logger: { level: config.logLevel },
}).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(import("@fastify/sensible"));
await app.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
await app.register(import("@fastify/rate-limit"), {
max: 300,
timeWindow: "1 minute",
});
await app.register(corsPlugin);
await app.register(errorHandlerPlugin);
await app.register(dbPlugin, { config, memory: opts.memory });
await app.register(authPlugin, { config });
await app.register(healthRoutes);
await app.register(authRoutes, { prefix: "/api/v1" });
await app.register(
async (protectedApi) => {
protectedApi.addHook("onRequest", requireAuth);
await protectedApi.register(settingsRoutes);
},
{ prefix: "/api/v1" },
);
const staticDir = config.staticDir ?? resolve(process.cwd(), "static");
if (config.staticDir !== null) {
await app.register(import("@fastify/static"), {
root: staticDir,
wildcard: false,
});
app.setNotFoundHandler(async (_request, reply) => {
return reply.sendFile("index.html");
});
}
return app;
}
+56
View File
@@ -0,0 +1,56 @@
import { resolve } from "node:path";
export interface AppConfig {
databaseUrl: string;
jwtSecret: string;
jwtTtlHours: number;
adminUsername: string;
adminPasswordHash: string;
serverPort: number;
staticDir: string | null;
logLevel: string;
/** Portal SSO — when true, require portal JWT with apps includes cdn */
authRequired: boolean;
authIssuer: string;
authPortalUrl: string;
/** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */
authAuditIngestSecret: string | null;
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback;
return v === "1" || v.toLowerCase() === "true";
}
export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === "production";
const jwtSecret =
process.env.AUTH_JWT_SECRET ??
process.env.JWT_SECRET ??
(isProd ? "" : "dev-secret-change-me");
return {
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
jwtSecret: jwtSecret || "dev-secret-change-me",
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
adminPasswordHash:
process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder",
serverPort: Number(process.env.SERVER_PORT ?? "8081") || 8081,
staticDir: process.env.STATIC_DIR
? resolve(process.env.STATIC_DIR)
: null,
logLevel: process.env.LOG_LEVEL ?? "info",
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
authIssuer:
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
authPortalUrl: (
process.env.AUTH_PORTAL_URL ??
process.env.VITE_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
authAuditIngestSecret:
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
(!isProd ? "dev-audit-ingest-secret" : null),
};
}
+112
View File
@@ -0,0 +1,112 @@
import { NotFoundError, ConflictError } from "@cdnmanager/db";
import { ValidationError } from "@cdnmanager/shared";
export type ErrorCode =
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "CONFLICT"
| "CLOUDFLARE_ERROR"
| "DNS_UPDATE_FAILED"
| "HEALTHCHECK_CREATE_FAILED"
| "ZONE_NOT_FOUND"
| "INVALID_IP"
| "INVALID_HOSTNAME"
| "RATE_LIMITED"
| "CLOUDFLARE_AUTH_FAILED"
| "SYNC_FAILED"
| "INTERNAL_ERROR";
export class AppError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = "AppError";
}
static notFound(message: string) {
return new AppError("NOT_FOUND", message, 404);
}
static validation(message: string) {
return new AppError("VALIDATION_ERROR", message, 400);
}
static unauthorized() {
return new AppError("UNAUTHORIZED", "unauthorized", 401);
}
static forbidden(message = "forbidden") {
return new AppError("FORBIDDEN", message, 403);
}
static conflict(message: string) {
return new AppError("CONFLICT", message, 409);
}
static cloudflare(message: string) {
return new AppError("CLOUDFLARE_ERROR", message, 502);
}
static dnsUpdateFailed(message: string) {
return new AppError(
"DNS_UPDATE_FAILED",
message,
502,
);
}
static healthcheckCreateFailed(message: string) {
return new AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
}
static zoneNotFound(message = "зона Cloudflare не найдена") {
return new AppError("ZONE_NOT_FOUND", message, 404);
}
static invalidIp(message = "Некорректный IP-адрес") {
return new AppError("INVALID_IP", message, 400);
}
static invalidHostname(message = "Некорректное имя хоста") {
return new AppError("INVALID_HOSTNAME", message, 400);
}
static rateLimited(message = "Cloudflare временно ограничил запросы. Повторите попытку.") {
return new AppError("RATE_LIMITED", message, 429);
}
static cloudflareAuthFailed(message = "Cloudflare отклонил токен доступа") {
return new AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
}
static syncFailed(message: string) {
return new AppError("SYNC_FAILED", message, 502);
}
static internal(message: string) {
return new AppError("INTERNAL_ERROR", message, 500);
}
}
export function toAppError(err: unknown): AppError {
if (err instanceof AppError) return err;
if (err instanceof NotFoundError) return AppError.notFound(err.message);
if (err instanceof ConflictError) return AppError.conflict(err.message);
if (err instanceof ValidationError) return AppError.validation(err.message);
if (err instanceof Error) return AppError.internal(err.message);
return AppError.internal(String(err));
}
export function errorBody(err: AppError) {
return {
error: {
code: err.code,
message: err.message,
},
};
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: cdn:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string;
email: string;
name: string;
apps: string[];
permissions: string[];
isAdmin?: boolean;
};
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true;
const parts = required.split(":");
if (parts.length !== 3) return false;
const [app, section, action] = parts;
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
);
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`);
}
return false;
}
type Rule = {
methods: string[];
match: (path: string) => boolean;
permission: string;
};
const RULES: Rule[] = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/v1/settings"),
permission: "cdn:settings:admin",
},
];
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase();
const pathname = path.split("?")[0] ?? path;
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue;
if (rule.match(pathname)) return rule.permission;
}
if (pathname.startsWith("/api/v1/")) return "cdn:dashboard:read";
return null;
}
+131
View File
@@ -0,0 +1,131 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import fp from "fastify-plugin";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from "../lib/permissions.js";
declare module "fastify" {
interface FastifyRequest {
authUser?: AuthUser;
}
interface FastifyInstance {
config: AppConfig;
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: {
sub: string;
email?: string;
name?: string;
apps?: string[];
permissions?: string[];
is_admin?: boolean;
iss?: string;
exp?: number;
};
user: {
sub: string;
email?: string;
name?: string;
apps?: string[];
permissions?: string[];
is_admin?: boolean;
iss?: string;
exp?: number;
};
}
}
async function authPlugin(
app: FastifyInstance,
opts: { config: AppConfig },
) {
const { config } = opts;
if (config.authRequired && (!config.jwtSecret || config.jwtSecret.length < 8)) {
throw new Error(
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true",
);
}
await app.register(import("@fastify/jwt"), {
secret: config.jwtSecret,
...(config.authRequired
? {
verify: {
allowedIss: [config.authIssuer],
},
}
: {}),
});
app.decorate("config", config);
if (config.authRequired) {
app.log.info(
{ issuer: config.authIssuer, portal: config.authPortalUrl },
"AUTH_REQUIRED=true — portal JWT middleware enabled",
);
} else {
app.log.info("AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth");
}
}
/**
* Protect /api/v1 routes.
* - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin).
* - AUTH_REQUIRED=true: portal JWT with apps.includes('cdn') + permissions.
*/
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const config = request.server.config;
const authHeader = request.headers.authorization ?? "";
const token = authHeader.startsWith("Bearer ")
? authHeader.slice(7)
: "";
if (!token) throw AppError.unauthorized();
try {
await request.jwtVerify();
} catch {
throw AppError.unauthorized();
}
if (!config.authRequired) {
return;
}
const payload = request.user;
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
const permissions = Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [];
if (!apps.includes("cdn")) {
throw AppError.forbidden("Нет доступа к приложению CDN Manager");
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps,
permissions,
isAdmin: Boolean(payload.is_admin),
};
const required = permissionForRequest(request.method, request.url);
if (required && !hasPermission(permissions, required)) {
throw AppError.forbidden(`Недостаточно прав: ${required}`);
}
}
export default fp(authPlugin, { name: "auth" });
+12
View File
@@ -0,0 +1,12 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
async function corsPlugin(app: FastifyInstance) {
await app.register(import("@fastify/cors"), {
origin: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
});
}
export default fp(corsPlugin, { name: "cors" });
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import {
createDb,
createMemoryDb,
healthCheck,
runMigrations,
type Db,
type Sqlite,
} from "@cdnmanager/db";
import type { AppConfig } from "../config.js";
declare module "fastify" {
interface FastifyInstance {
db: Db;
sqlite: Sqlite;
}
}
export interface DbPluginOptions {
config?: AppConfig;
memory?: boolean;
}
async function dbPlugin(
app: FastifyInstance,
opts: DbPluginOptions,
) {
const { db, sqlite } = opts.memory
? createMemoryDb()
: createDb(opts.config!.databaseUrl);
runMigrations(sqlite);
app.decorate("db", db);
app.decorate("sqlite", sqlite);
app.addHook("onClose", async () => {
sqlite.close();
});
}
export default fp(dbPlugin, { name: "db" });
export { healthCheck };
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import { AppError, errorBody, toAppError } from "../errors.js";
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _request, reply) => {
if (reply.sent) return;
const appErr =
err.statusCode === 401
? AppError.unauthorized()
: toAppError(err);
reply.status(appErr.statusCode).send(errorBody(appErr));
});
}
export default fp(errorHandlerPlugin, { name: "error-handler" });
+52
View File
@@ -0,0 +1,52 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { healthCheck } from "../plugins/db.js";
import * as authService from "../services/auth.js";
export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async (request) => {
healthCheck(request.server.sqlite);
return { status: "ok" };
});
app.get("/ready", async (request) => {
healthCheck(request.server.sqlite);
return {
status: "ready",
database: true,
};
});
}
export async function authRoutes(app: FastifyInstance) {
app.get("/auth/config", async (request) => {
const { config } = request.server;
return {
required: config.authRequired,
portal_url: config.authPortalUrl,
};
});
const loginSchema = z.object({
username: z.string(),
password: z.string(),
});
app.post("/auth/login", async (request, reply) => {
if (request.server.config.authRequired) {
return reply.code(403).send({
error: {
code: "FORBIDDEN",
message: "Локальный вход отключён — используйте auth-portal",
},
});
}
const body = loginSchema.parse(request.body);
const result = await authService.login(
request.server.config,
(payload) => request.server.jwt.sign(payload),
body,
);
return result;
});
}
+20
View File
@@ -0,0 +1,20 @@
import type { FastifyInstance } from "fastify";
import { appSettingsPatchSchema } from "@cdnmanager/shared";
import { getAppSettings, updateAppSettings } from "@cdnmanager/db";
import { AppError } from "../errors.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
return getAppSettings(request.server.db);
});
app.patch("/settings", async (request) => {
const parsed = appSettingsPatchSchema.safeParse(request.body);
if (!parsed.success) {
throw AppError.validation(
parsed.error.issues[0]?.message ?? "некорректные настройки",
);
}
return updateAppSettings(request.server.db, parsed.data);
});
}
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
for (const path of [
resolve(import.meta.dirname, "../../../.env"),
".env",
"../.env",
]) {
if (!existsSync(path)) continue;
const content = readFileSync(path, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
break;
}
const config = loadConfig();
const app = await buildApp({ config });
try {
await app.listen({ port: config.serverPort, host: "0.0.0.0" });
app.log.info(`listening on ${config.serverPort}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
+40
View File
@@ -0,0 +1,40 @@
import { verify } from "@node-rs/argon2";
import type { JwtClaims, LoginRequest, LoginResponse } from "@cdnmanager/shared";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
export async function verifyPassword(
config: AppConfig,
password: string,
): Promise<void> {
if (config.adminPasswordHash === "devplaceholder") {
if (password === "admin") return;
throw AppError.unauthorized();
}
const ok = await verify(config.adminPasswordHash, password);
if (!ok) throw AppError.unauthorized();
}
export async function login(
config: AppConfig,
sign: (payload: JwtClaims) => string,
req: LoginRequest,
): Promise<LoginResponse> {
if (req.username !== config.adminUsername) {
throw AppError.unauthorized();
}
await verifyPassword(config, req.password);
const expiresAt = new Date(
Date.now() + config.jwtTtlHours * 60 * 60 * 1000,
);
const token = sign({
sub: req.username,
exp: Math.floor(expiresAt.getTime() / 1000),
});
return {
token,
expires_at: expiresAt.toISOString(),
};
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
describe("health", () => {
it("GET /health returns ok", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
await app.close();
});
it("GET /ready returns database status", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const res = await app.inject({ method: "GET", url: "/ready" });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.database).toBe(true);
expect(["ready", "degraded"]).toContain(body.status);
await app.close();
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src", "test"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/server.ts"],
format: ["esm"],
dts: true,
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
testTimeout: 20_000,
typecheck: {
tsconfig: "./tsconfig.test.json",
},
},
});
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
},
"aliases": {
"components": "@/components",
"utils": "@cdnmanager/ui/lib/utils",
"ui": "@cdnmanager/ui/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle"
}
+101
View File
@@ -0,0 +1,101 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
const rawHtmlElements = ['table', 'select', 'hr']
export default defineConfig([
globalIgnores(['dist', 'src/components/blocks/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
rules: {
'react-hooks/set-state-in-effect': 'off',
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/components/ui/*'],
message: 'Импортируйте примитивы через @cdnmanager/ui/components/*',
},
],
},
],
'no-restricted-syntax': [
'error',
...rawHtmlElements.map((name) => ({
selector: `JSXOpeningElement[name.name="${name}"]`,
message: `Используйте shadcn-компонент вместо <${name}>`,
})),
{
selector:
'JSXAttribute[name.name="className"] Literal[value=/\\bspace-[xy]-/]',
message: 'Используйте flex + gap-* вместо space-y-* / space-x-*',
},
{
selector:
'JSXAttribute[name.name="className"] Literal[value=/\\bbg-(emerald|red|green|blue|yellow|orange)-/]',
message: 'Используйте semantic tokens (bg-primary, bg-muted и т.д.)',
},
{
selector:
'JSXAttribute[name.name="className"] Literal[value=/\\btext-(blue|red|green)-/]',
message: 'Используйте semantic tokens (text-foreground, text-muted-foreground)',
},
],
},
},
{
files: ['src/routes/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
files: ['src/components/tagged-input.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
files: [
'**/services-board/*-row.tsx',
'**/groups-board/*-row.tsx',
'**/layout/app-shell.tsx',
],
rules: {
'no-restricted-syntax': 'off',
},
},
{
files: [
'src/components/reui/**/*.{ts,tsx}',
'src/components/reui-kit/**/*.{ts,tsx}',
'src/components/columns/**/*.{ts,tsx}',
],
rules: {
'react-refresh/only-export-components': 'off',
'react-hooks/incompatible-library': 'off',
'react-hooks/use-memo': 'off',
'react-hooks/immutability': 'off',
'react-hooks/exhaustive-deps': 'off',
'no-restricted-syntax': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'prefer-const': 'off',
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CDN Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsr generate && tsc -b && vite build",
"test": "vitest run",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@cdnmanager/shared": "workspace:*",
"@cdnmanager/ui": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.4",
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.18.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-hook-form": "^7.79.0",
"react-phone-number-input": "^3.4.17",
"recharts": "^3.8.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tanstack/router-cli": "^1.167.31",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tw-animate-css": "^1.0.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.8"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+33
View File
@@ -0,0 +1,33 @@
import type { ComponentProps } from 'react'
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { cn } from '@cdnmanager/ui/lib/utils'
export type AppAlertProps = ComponentProps<typeof Alert>
export type AppAlertTitleProps = ComponentProps<typeof AlertTitle>
export type AppAlertDescriptionProps = ComponentProps<typeof AlertDescription>
export type AppAlertActionProps = ComponentProps<typeof AlertAction>
/** Thin alias over ReUI Alert — prefer importing from `@/components/reui/alert` in new code. */
export function AppAlert({ className, ...props }: AppAlertProps) {
return <Alert className={cn(className)} {...props} />
}
export function AppAlertTitle({ className, ...props }: AppAlertTitleProps) {
return <AlertTitle className={cn(className)} {...props} />
}
export function AppAlertDescription({
className,
...props
}: AppAlertDescriptionProps) {
return <AlertDescription className={cn(className)} {...props} />
}
export function AppAlertAction({ className, ...props }: AppAlertActionProps) {
return <AlertAction className={cn(className)} {...props} />
}
+10
View File
@@ -0,0 +1,10 @@
import type { ComponentProps } from 'react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cdnmanager/ui/lib/utils'
export type AppBadgeProps = ComponentProps<typeof Badge>
/** Thin alias over ReUI Badge — prefer importing Badge/StatusBadge directly in new code. */
export function AppBadge({ className, ...props }: AppBadgeProps) {
return <Badge className={cn(className)} {...props} />
}
+9
View File
@@ -0,0 +1,9 @@
import type { ComponentProps } from 'react'
import { Button } from '@cdnmanager/ui/components/button'
import { cn } from '@cdnmanager/ui/lib/utils'
export type AppButtonProps = ComponentProps<typeof Button>
export function AppButton({ className, ...props }: AppButtonProps) {
return <Button className={cn(className)} {...props} />
}
+71
View File
@@ -0,0 +1,71 @@
import type { ComponentProps } from 'react'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from '@cdnmanager/ui/components/field'
import { cn } from '@cdnmanager/ui/lib/utils'
export type AppFieldProps = ComponentProps<typeof Field>
export type AppFieldGroupProps = ComponentProps<typeof FieldGroup>
export type AppFieldLabelProps = ComponentProps<typeof FieldLabel>
export type AppFieldDescriptionProps = ComponentProps<typeof FieldDescription>
export type AppFieldErrorProps = ComponentProps<typeof FieldError>
export type AppFieldContentProps = ComponentProps<typeof FieldContent>
export type AppFieldTitleProps = ComponentProps<typeof FieldTitle>
export type AppFieldLegendProps = ComponentProps<typeof FieldLegend>
export type AppFieldSeparatorProps = ComponentProps<typeof FieldSeparator>
export type AppFieldSetProps = ComponentProps<typeof FieldSet>
export function AppField({ className, ...props }: AppFieldProps) {
return <Field className={cn(className)} {...props} />
}
export function AppFieldGroup({ className, ...props }: AppFieldGroupProps) {
return <FieldGroup className={cn(className)} {...props} />
}
export function AppFieldLabel({ className, ...props }: AppFieldLabelProps) {
return <FieldLabel className={cn(className)} {...props} />
}
export function AppFieldDescription({
className,
...props
}: AppFieldDescriptionProps) {
return <FieldDescription className={cn(className)} {...props} />
}
export function AppFieldError({ className, ...props }: AppFieldErrorProps) {
return <FieldError className={cn(className)} {...props} />
}
export function AppFieldContent({ className, ...props }: AppFieldContentProps) {
return <FieldContent className={cn(className)} {...props} />
}
export function AppFieldTitle({ className, ...props }: AppFieldTitleProps) {
return <FieldTitle className={cn(className)} {...props} />
}
export function AppFieldLegend({ className, ...props }: AppFieldLegendProps) {
return <FieldLegend className={cn(className)} {...props} />
}
export function AppFieldSeparator({
className,
...props
}: AppFieldSeparatorProps) {
return <FieldSeparator className={cn(className)} {...props} />
}
export function AppFieldSet({ className, ...props }: AppFieldSetProps) {
return <FieldSet className={cn(className)} {...props} />
}
+9
View File
@@ -0,0 +1,9 @@
import type { ComponentProps } from 'react'
import { Input } from '@cdnmanager/ui/components/input'
import { cn } from '@cdnmanager/ui/lib/utils'
export type AppInputProps = ComponentProps<typeof Input>
export function AppInput({ className, ...props }: AppInputProps) {
return <Input className={cn(className)} {...props} />
}
+71
View File
@@ -0,0 +1,71 @@
import type { ComponentProps } from 'react'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemGroup,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle,
} from '@cdnmanager/ui/components/item'
import { cn } from '@cdnmanager/ui/lib/utils'
export type AppItemProps = ComponentProps<typeof Item>
export type AppItemGroupProps = ComponentProps<typeof ItemGroup>
export type AppItemSeparatorProps = ComponentProps<typeof ItemSeparator>
export type AppItemMediaProps = ComponentProps<typeof ItemMedia>
export type AppItemContentProps = ComponentProps<typeof ItemContent>
export type AppItemTitleProps = ComponentProps<typeof ItemTitle>
export type AppItemDescriptionProps = ComponentProps<typeof ItemDescription>
export type AppItemActionsProps = ComponentProps<typeof ItemActions>
export type AppItemHeaderProps = ComponentProps<typeof ItemHeader>
export type AppItemFooterProps = ComponentProps<typeof ItemFooter>
export function AppItem({ className, ...props }: AppItemProps) {
return <Item className={cn(className)} {...props} />
}
export function AppItemGroup({ className, ...props }: AppItemGroupProps) {
return <ItemGroup className={cn(className)} {...props} />
}
export function AppItemSeparator({
className,
...props
}: AppItemSeparatorProps) {
return <ItemSeparator className={cn(className)} {...props} />
}
export function AppItemMedia({ className, ...props }: AppItemMediaProps) {
return <ItemMedia className={cn(className)} {...props} />
}
export function AppItemContent({ className, ...props }: AppItemContentProps) {
return <ItemContent className={cn(className)} {...props} />
}
export function AppItemTitle({ className, ...props }: AppItemTitleProps) {
return <ItemTitle className={cn(className)} {...props} />
}
export function AppItemDescription({
className,
...props
}: AppItemDescriptionProps) {
return <ItemDescription className={cn(className)} {...props} />
}
export function AppItemActions({ className, ...props }: AppItemActionsProps) {
return <ItemActions className={cn(className)} {...props} />
}
export function AppItemHeader({ className, ...props }: AppItemHeaderProps) {
return <ItemHeader className={cn(className)} {...props} />
}
export function AppItemFooter({ className, ...props }: AppItemFooterProps) {
return <ItemFooter className={cn(className)} {...props} />
}
+86
View File
@@ -0,0 +1,86 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { LayoutDashboardIcon, SettingsIcon } from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@cdnmanager/ui/components/sidebar'
const mainNav = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
{
to: '/settings/appearance',
label: 'Настройки',
icon: SettingsIcon,
exact: false,
matchPrefix: '/settings',
},
] as const
function isNavActive(
pathname: string,
to: string,
exact: boolean,
matchPrefix?: string,
) {
if (matchPrefix) {
return pathname === matchPrefix || pathname.startsWith(`${matchPrefix}/`)
}
if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`)
}
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>CDN Manager</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mainNav.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(
pathname,
item.to,
item.exact,
'matchPrefix' in item ? item.matchPrefix : undefined,
)}
render={
<Link
to={item.to}
activeOptions={{ exact: item.exact }}
/>
}
>
<item.icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}
+96
View File
@@ -0,0 +1,96 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@cdnmanager/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@cdnmanager/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem key={app.id} disabled>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,56 @@
import type { ReactElement } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@cdnmanager/ui/components/alert-dialog'
interface ConfirmDialogProps {
trigger?: ReactElement
open?: boolean
onOpenChange?: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
onConfirm: () => void
disabled?: boolean
}
export function ConfirmDialog({
trigger,
open,
onOpenChange,
title,
description,
confirmLabel = 'Удалить',
cancelLabel = 'Отмена',
onConfirm,
disabled,
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
{trigger ? (
<AlertDialogTrigger disabled={disabled} render={trigger} />
) : null}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@cdnmanager/ui/components/tabs'
import { cn } from '@cdnmanager/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { cn } from '@cdnmanager/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
export function dataGridCellStack(
primary: ReactNode,
secondary?: ReactNode,
className?: string,
) {
return (
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
{typeof primary === 'string' || typeof primary === 'number' ? (
<TruncatedText className="font-medium">{primary}</TruncatedText>
) : (
<span className="truncate font-medium">{primary}</span>
)}
{secondary ? (
typeof secondary === 'string' || typeof secondary === 'number' ? (
<TruncatedText className="max-w-[14rem] text-xs text-muted-foreground">{secondary}</TruncatedText>
) : (
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
)
) : null}
</div>
)
}
export function dataGridCellWithIcon(
icon: ReactNode,
children: ReactNode,
className?: string,
) {
return (
<div className={cn('flex items-center gap-2', className)}>
<span className="shrink-0 text-muted-foreground">{icon}</span>
{children}
</div>
)
}
export function dataGridCellWithFlag(
flag: ReactNode,
primary: ReactNode,
secondary?: ReactNode,
) {
return (
<div className="flex items-center gap-2.5">
<span className="flex size-4 shrink-0 items-center justify-center">{flag}</span>
{secondary ? dataGridCellStack(primary, secondary) : <span className="font-medium">{primary}</span>}
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import { IconTile } from '@/components/reui/icon-tile'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@cdnmanager/ui/components/empty'
import { cn } from '@cdnmanager/ui/lib/utils'
interface EmptyStateProps {
icon?: LucideIcon
title: string
description?: string
action?: React.ReactNode
className?: string
/** Use IconStack media (empty-state-12). Default true. */
stackedIcon?: boolean
/**
* Center in available width/height (empty-state-12).
* Preview: https://reui.io/preview/base/empty-state-12
* Set false for tight panels/sheets.
*/
centered?: boolean
}
/**
* ReUI Empty + IconStack — Frame-friendly empty-state-14 / empty-state-12.
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
*/
export function EmptyState({
icon: Icon = InboxIcon,
title,
description,
action,
className,
stackedIcon = true,
centered = true,
}: EmptyStateProps) {
const body = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
)}
>
<EmptyHeader className={cn('text-center', stackedIcon ? 'gap-5' : 'gap-3')}>
<EmptyMedia className="mb-0">
{stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<IconTile
variant="elevated"
className="size-10.5 text-muted-foreground"
aria-hidden="true"
>
<Icon />
</IconTile>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle
className={cn(
'font-semibold tracking-tight',
stackedIcon ? 'text-base' : 'text-sm',
)}
>
{title}
</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">
{description}
</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action ? (
<EmptyContent className="mt-1 items-center justify-center">
{action}
</EmptyContent>
) : null}
</Empty>
)
if (!centered) return body
return (
<div
className={cn(
'flex w-full flex-1 items-center justify-center py-14 sm:py-16',
className,
)}
>
{body}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@cdnmanager/ui/components/field'
import { cn } from '@cdnmanager/ui/lib/utils'
interface FormFieldSimpleProps {
label: string
htmlFor: string
error?: { message?: string }
hint?: string
className?: string
children: ReactNode
}
export function FormFieldSimple({
label,
htmlFor,
error,
hint,
className,
children,
}: FormFieldSimpleProps) {
return (
<Field data-invalid={!!error} className={cn(className)}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
<FieldError errors={[error]} />
</Field>
)
}
+71
View File
@@ -0,0 +1,71 @@
import type { ReactNode } from 'react'
import type { FieldValues, UseFormReturn } from 'react-hook-form'
import { FormProvider } from 'react-hook-form'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cdnmanager/ui/components/sheet'
import { cn } from '@cdnmanager/ui/lib/utils'
interface FormSheetProps<T extends FieldValues> {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
form: UseFormReturn<T>
onSubmit: (values: T) => void | Promise<void>
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
}
export function FormSheet<T extends FieldValues>({
open,
onOpenChange,
title,
description,
form,
onSubmit,
children,
footer,
className,
contentClassName,
}: FormSheetProps<T>) {
const handleSubmit = form.handleSubmit(onSubmit)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className={cn('flex flex-col gap-0 overflow-hidden', className)}
>
<SheetHeader className="shrink-0">
<SheetTitle>{title}</SheetTitle>
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<FormProvider {...form}>
<form
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div
className={cn(
'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4',
contentClassName,
)}
>
{children}
</div>
{footer ? (
<SheetFooter className="shrink-0 border-t">{footer}</SheetFooter>
) : null}
</form>
</FormProvider>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,34 @@
import type { CSSProperties, ReactNode } from 'react'
import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/layout/site-header'
import { SearchMenu } from '@/components/layout/search-menu'
import { TooltipProvider } from '@cdnmanager/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@cdnmanager/ui/components/sidebar'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
export function AppShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<SearchMenu hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,105 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@cdnmanager/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cdnmanager/ui/components/dropdown-menu'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={portalAppsUrl} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/appearance" />}
className="justify-center text-sm font-medium"
>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,123 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import {
LayoutDashboardIcon,
SearchIcon,
SettingsIcon,
} from 'lucide-react'
import { Button } from '@cdnmanager/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@cdnmanager/ui/components/dialog'
import { Input } from '@cdnmanager/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cdnmanager/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Панель управления',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/settings/appearance',
label: 'Настройки',
keywords: ['settings', 'внешний вид', 'appearance'],
icon: SettingsIcon,
},
] as const
/** Command-K search — hotkey dialog (no header chrome trigger). */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setOpen((v) => !v)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return NAV_ITEMS
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.keywords.some((k) => k.includes(q)),
)
}, [query])
return (
<>
{hotkeyOnly ? null : (
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => setOpen(true)}
aria-label="Поиск"
>
<SearchIcon className="size-4" aria-hidden />
<span className="hidden sm:inline">Поиск</span>
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="gap-3 sm:max-w-lg">
<DialogHeader>
<DialogTitle>Поиск</DialogTitle>
<DialogDescription>Переход по разделам CDN Manager</DialogDescription>
</DialogHeader>
<Input
id={searchInputId}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Найти…"
autoFocus
/>
<ItemGroup className="max-h-72 overflow-y-auto">
{filtered.map((item) => (
<Item
key={item.to}
className="cursor-pointer"
onClick={() => {
setOpen(false)
void navigate({ to: item.to })
}}
>
<ItemMedia variant="icon">
<item.icon className="size-4" aria-hidden />
</ItemMedia>
<ItemContent>
<ItemTitle>
<Link to={item.to} onClick={() => setOpen(false)}>
{item.label}
</Link>
</ItemTitle>
</ItemContent>
</Item>
))}
</ItemGroup>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,79 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@cdnmanager/ui/components/breadcrumb'
import { Separator } from '@cdnmanager/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@cdnmanager/ui/components/sidebar'
import { getBreadcrumbs } from '@/lib/breadcrumbs'
export interface RouteBreadcrumbLoaderData {
breadcrumb?: string
}
function useDynamicBreadcrumbLabels() {
const matches = useMatches()
return useMemo(() => {
const labels: Record<string, string> = {}
for (const match of matches) {
const data = match.loaderData as RouteBreadcrumbLoaderData | undefined
if (data?.breadcrumb && match.pathname) {
labels[match.pathname] = data.breadcrumb
}
}
return labels
}, [matches])
}
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
const crumbs = useMemo(
() => getBreadcrumbs(pathname, dynamicLabels),
[pathname, dynamicLabels],
)
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return (
<Fragment key={`${index}-${crumb.href}`}>
{index > 0 ? (
<BreadcrumbSeparator className="hidden md:block" />
) : null}
<BreadcrumbItem
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
>
{isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to={crumb.href} />}>
{crumb.label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
)
}
@@ -0,0 +1,193 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, Database, HeartPulse, LayoutDashboard } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cdnmanager/ui/lib/utils'
import { Item, ItemMedia } from '@cdnmanager/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@cdnmanager/ui/components/popover'
import { Progress } from '@cdnmanager/ui/components/progress'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
type ReadyResponse = {
status: string
database?: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<Progress
value={metric.percent}
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ '--bar-color': color } as CSSProperties}
/>
</div>
)
}
/** Live system monitor popover — skeleton metrics until CDN domain APIs exist. */
export function SystemMonitorPopover() {
const readyQ = useQuery({
queryKey: ['ready'],
queryFn: async (): Promise<ReadyResponse> => {
const res = await fetch('/ready')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<ReadyResponse>
},
refetchInterval: 30_000,
})
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
const readyDegraded = readyQ.isError
const dbOk = readyQ.data?.database === true
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'ready',
label: 'Ready',
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
unit: '',
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
icon: <HeartPulse aria-hidden />,
tone: readyOk ? 'success' : 'destructive',
alert: !readyOk,
},
{
id: 'db',
label: 'SQLite',
value: dbOk ? 'OK' : '—',
unit: '',
percent: dbOk ? 100 : 0,
icon: <Database aria-hidden />,
tone: dbOk ? 'success' : 'warning',
alert: readyQ.isSuccess && !dbOk,
},
{
id: 'app',
label: 'Приложение',
value: 'CDN',
unit: '',
percent: 100,
icon: <LayoutDashboard aria-hidden />,
tone: 'info',
alert: false,
},
{
id: 'status',
label: 'Каркас',
value: '1',
unit: '',
percent: 100,
icon: <Activity aria-hidden />,
tone: 'success',
alert: false,
},
],
[dbOk, readyDegraded, readyOk, readyQ.isSuccess],
)
const spiking = metrics.some((m) => m.alert)
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор CDN Manager</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,32 @@
import type { ComponentProps } from 'react'
import { Button } from '@cdnmanager/ui/components/button'
import { Spinner } from '@cdnmanager/ui/components/spinner'
import { cn } from '@cdnmanager/ui/lib/utils'
interface LoadingButtonProps extends ComponentProps<typeof Button> {
isLoading?: boolean
loadingLabel?: string
}
export function LoadingButton({
isLoading = false,
loadingLabel,
children,
disabled,
className,
...props
}: LoadingButtonProps) {
const label =
isLoading && loadingLabel != null ? loadingLabel : children
return (
<Button
disabled={disabled ?? isLoading}
className={cn(className)}
{...props}
>
{isLoading && <Spinner data-icon="inline-start" />}
{label}
</Button>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useNavigate } from '@tanstack/react-router'
import { CircleAlertIcon } from 'lucide-react'
import { cn } from '@cdnmanager/ui/lib/utils'
import { api } from '@/lib/api-client'
import { setToken } from '@/lib/auth'
import { loginSchema, type LoginInput } from '@cdnmanager/shared'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { FieldGroup } from '@cdnmanager/ui/components/field'
import { Input } from '@cdnmanager/ui/components/input'
type LoginFormProps = React.ComponentProps<'div'>
export function LoginForm({ className, ...props }: LoginFormProps) {
const navigate = useNavigate()
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { username: 'admin', password: 'admin' },
})
const handleSubmit = form.handleSubmit(async (values) => {
try {
const res = await api.post<{ token: string }>('/api/v1/auth/login', values)
setToken(res.token)
navigate({ to: '/' })
} catch (err) {
form.setError('root', {
message: err instanceof Error ? err.message : 'Не удалось войти',
})
}
})
const rootError = form.formState.errors.root?.message
const isLoading = form.formState.isSubmitting
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Frame dense spacing="default" className="w-full">
<FrameHeader className="text-center">
<FrameTitle className="text-xl">Вход в систему</FrameTitle>
<FrameDescription>
Введите учётные данные для доступа к панели управления
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit}>
<FieldGroup>
<FormFieldSimple
label="Имя пользователя"
htmlFor="username"
error={form.formState.errors.username}
>
<Input
id="username"
placeholder="admin"
autoComplete="username"
className="bg-background"
{...form.register('username')}
aria-invalid={!!form.formState.errors.username}
/>
</FormFieldSimple>
<FormFieldSimple
label="Пароль"
htmlFor="password"
error={form.formState.errors.password}
>
<Input
id="password"
type="password"
autoComplete="current-password"
className="bg-background"
{...form.register('password')}
aria-invalid={!!form.formState.errors.password}
/>
</FormFieldSimple>
{rootError ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка входа</AlertTitle>
<AlertDescription>{rootError}</AlertDescription>
</Alert>
) : null}
<LoadingButton
type="submit"
className="w-full"
isLoading={isLoading}
loadingLabel="Вход…"
>
Войти
</LoadingButton>
</FieldGroup>
</form>
</FramePanel>
</Frame>
</div>
)
}
+219
View File
@@ -0,0 +1,219 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@cdnmanager/ui/lib/utils'
import {
Avatar,
AvatarFallback,
} from '@cdnmanager/ui/components/avatar'
import { Button } from '@cdnmanager/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cdnmanager/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@cdnmanager/ui/components/sidebar'
import {
can,
clearToken,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(name: string, email: string): string {
const base = name.trim() || email.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const claims = getClaims()
const authOn = isAuthEnabled()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен')
const fallback = initials(name, email)
function handleSignOut() {
clearToken()
resetPortalHandoff()
if (authOn) {
redirectToPortalLogout()
return
}
window.location.href = '/login'
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{can('cdn:settings:admin') ? (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/appearance" />}
>
<SettingsIcon aria-hidden />
Настройки
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: ReactNode
actions?: ReactNode
}
/** Page-level section header — etalon EvoBGP / Domains list. */
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import { cn } from '@cdnmanager/ui/lib/utils'
interface PageShellProps {
children: ReactNode
className?: string
}
export function PageShell({ children, className }: PageShellProps) {
return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
{children}
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { CircleAlertIcon } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@cdnmanager/ui/components/button'
import { Skeleton } from '@cdnmanager/ui/components/skeleton'
interface QueryStateProps {
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
skeleton?: React.ReactNode
children: React.ReactNode
}
function DefaultSkeleton() {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
)
}
export function QueryState({
isLoading,
isError,
error,
onRetry,
skeleton,
children,
}: QueryStateProps) {
if (isLoading) {
return (
<div role="status" aria-live="polite" aria-busy="true">
{skeleton ?? <DefaultSkeleton />}
</div>
)
}
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Не удалось загрузить данные</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Произошла ошибка при загрузке.'}</span>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
return children
}
@@ -0,0 +1,116 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@cdnmanager/ui/lib/utils'
export interface DetailMetricCard {
id: string
icon: ReactNode
label: string
description: string
footer?: ReactNode
}
interface DetailPanelProps {
children: ReactNode
className?: string
}
function DetailPanelRoot({ children, className }: DetailPanelProps) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
interface DetailPanelHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
}
function DetailPanelHeader({
title,
description,
actions,
children,
}: DetailPanelHeaderProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
</Frame>
)
}
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
return (
<div className="@container w-full">
<div className="grid gap-4 @2xl:grid-cols-3">
{cards.map((card) => (
<Frame key={card.id} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
{card.footer}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
function DetailPanelSection({
title,
description,
children,
}: {
title?: string
description?: string
children: ReactNode
}) {
return (
<section className="flex flex-col gap-3">
{title ? (
<header className="px-1">
<h2 className="text-sm font-semibold">{title}</h2>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
) : null}
{children}
</section>
)
}
export const DetailPanel = Object.assign(DetailPanelRoot, {
Header: DetailPanelHeader,
Metrics: DetailPanelMetrics,
Section: DetailPanelSection,
})
@@ -0,0 +1,91 @@
import type { Filter } from '@/components/reui/filters'
export function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
export function applyFiltersToData<T>(
data: T[],
filters: Filter[],
getFieldValue: (item: T, field: string) => unknown,
): T[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = getFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
switch (operator) {
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((value) => fieldValue === value)
case 'is_not_any_of':
return !values.some((value) => fieldValue === value)
case 'contains': {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
)
case 'starts_with':
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
)
case 'ends_with':
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
})
}
return result
}
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return null
}
export function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[],
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
+17
View File
@@ -0,0 +1,17 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -0,0 +1,10 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
@@ -0,0 +1,331 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cdnmanager/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@cdnmanager/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
/**
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
to?: string
search?: Record<string, unknown>
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
/** CDNManager-compatible card shape (id required). */
export type KpiStatCardData = KpiStatItem & { id: string }
/** @deprecated Use KpiStatCardData */
export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CDNManager kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
return (
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
className,
)
}
/** Single KPI tile — used for embedded / standalone contexts. */
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
/** EvoBGP primary API */
items?: KpiStatItem[]
/** CDNManager-compatible API */
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
/** Wrap each tile in its own Frame (analytics panels). */
embedded?: boolean
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react'
import { Skeleton } from '@cdnmanager/ui/components/skeleton'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
export type OpsKpiCard = KpiStatCard
interface OpsDashboardProps {
kpiCards: OpsKpiCard[]
/** Optional slot under KPI (e.g. QuickActionGrid) */
afterKpi?: ReactNode
charts: ReactNode
queue: ReactNode
queueTitle?: string
queueDescription?: string
isLoading?: boolean
}
/**
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
* Queue is a sibling Frame grid — never wrap Frames inside another Frame.
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/docs/components/base/frame
*/
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
function OpsDashboardSkeleton() {
return (
<div className={rootClassName}>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
<div className="grid gap-2 @3xl:grid-cols-3">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
</div>
)
}
export function OpsDashboard({
kpiCards,
afterKpi,
charts,
queue,
queueTitle = 'Требуют внимания',
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
return <OpsDashboardSkeleton />
}
return (
<div className={rootClassName}>
<section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section>
{afterKpi}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
{charts}
</section>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">
{queueDescription}
</p>
) : null}
</div>
{queue}
</section>
</div>
)
}
@@ -0,0 +1,101 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cdnmanager/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
title: string
description: string
to: string
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
</FramePanel>
))}
</div>
</Frame>
)
}
@@ -0,0 +1,391 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cdnmanager/ui/components/button'
import { Separator } from '@cdnmanager/ui/components/separator'
import { Skeleton } from '@cdnmanager/ui/components/skeleton'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab {
id: string
label: string
count?: number
}
export interface ResourcePageProps<T extends object> {
title: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
filterFields: FilterFieldConfig[]
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[]
data: T[]
getRowId: (row: T) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
toolbarExtra?: ReactNode
hideHeader?: boolean
}
function ResourcePageSkeleton() {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</FramePanel>
</Frame>
)
}
export function ResourcePage<T extends object>({
title,
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields,
filters,
onFiltersChange,
onClearFilters,
getFilterFieldValue,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
emptyState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
}: ResourcePageProps<T>) {
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
})
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
)
}, [])
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
const base = applyFiltersToData(data, filters, getFilterFieldValue)
const counts: Record<string, number> = {}
for (const tab of tabs) {
counts[tab.id] =
tab.id === 'all'
? base.length
: base.filter((item) => tabFilter(item, tab.id)).length
}
return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedCount = selectedIds.length
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
const table = useReactTable({
data: filteredData,
columns,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const handleTabChange = useCallback(
(value: string) => {
if (onTabChange) onTabChange(value)
else setInternalTab(value)
resetPagination()
},
[onTabChange, resetPagination],
)
const handleFiltersChange = useCallback(
(next: Filter[]) => {
onFiltersChange(next)
resetPagination()
},
[onFiltersChange, resetPagination],
)
const handleClear = useCallback(() => {
onClearFilters?.()
resetPagination()
}, [onClearFilters, resetPagination])
const countedTabs = useMemo(
() =>
(tabs ?? []).map((tab) => ({
id: tab.id,
label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0,
})),
[tabs, tabCounts],
)
if (isLoading) {
return <ResourcePageSkeleton />
}
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
if (data.length === 0 && emptyState) {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="text-xs text-pretty">
{description}
</FrameDescription>
) : null}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
</FramePanel>
</Frame>
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
return (
<div className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage={emptyMessage}
tableLayout={{ dense: true }}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
) : null}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
/>
</div>
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FunnelXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</div>
)
}
@@ -0,0 +1,89 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon } from 'lucide-react'
import { useIsMobile } from '@cdnmanager/ui/hooks/use-mobile'
import { cn } from '@cdnmanager/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'appearance',
to: '/settings/appearance',
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид приложения',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
isActive
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
<span>{tab.label}</span>
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
</PageShell>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cdnmanager/ui/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
@@ -0,0 +1,343 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cdnmanager/ui/lib/utils"
import { ScrollArea } from "@cdnmanager/ui/components/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}
+102
View File
@@ -0,0 +1,102 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cdnmanager/ui/lib/utils"
const badgeVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light":
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light":
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light":
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light":
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light":
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light":
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: active style radius. `full`: pill radius. */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,186 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { cn } from "@cdnmanager/ui/lib/utils"
import { Button } from "@cdnmanager/ui/components/button"
import { Input } from "@cdnmanager/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@cdnmanager/ui/components/popover"
import { Separator } from "@cdnmanager/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
}}
className={cn(
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground h-4 w-4" />
)}
<span>{option.label}</span>
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facetCount}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,347 @@
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table"
import { cn } from "@cdnmanager/ui/lib/utils"
import { Button } from "@cdnmanager/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@cdnmanager/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.getState().columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,53 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@cdnmanager/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,221 @@
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@cdnmanager/ui/lib/utils"
import { Button } from "@cdnmanager/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@cdnmanager/ui/components/select"
import { Skeleton } from "@cdnmanager/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps.info
? mergedProps.info
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-16" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps.infoSkeleton
) : (
<>
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,426 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@cdnmanager/ui/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,302 @@
import {
createContext,
CSSProperties,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
import { cn } from "@cdnmanager/ui/lib/utils"
import { Button } from "@cdnmanager/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
disabled
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,319 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table"
import { Button } from "@cdnmanager/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,597 @@
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@cdnmanager/ui/lib/utils"
import { Spinner } from "@cdnmanager/ui/components/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
column,
}: {
column: Column<TData>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: Table<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData>({
table,
height,
}: {
table: Table<TData>
height: number
}) {
if (height <= 0) return null
return (
<DataGridTableVirtualUtilityRow
table={table}
ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualStatusRow<TData>({
table,
children,
className,
}: {
table: Table<TData>
children: ReactNode
className?: string
}) {
return (
<DataGridTableVirtualUtilityRow
table={table}
centerCellClassName={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualBody<TData>({
table,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
table={table}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
table={table}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
table={table}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
import { createContext, ReactNode, useContext, useMemo } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@cdnmanager/ui/lib/utils"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
footerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: false,
footerBackground: false,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border: _border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn("w-full overflow-hidden", className)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cdnmanager/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,90 @@
import { cn } from "@cdnmanager/ui/lib/utils"
type IconStackProps = React.ComponentProps<"div">
function IconStack({ className, children, style, ...props }: IconStackProps) {
return (
<div
data-slot="icon-stack"
className={cn(
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
className
)}
style={
{
"--icon-stack-content-x": "71%",
"--icon-stack-content-y": "58%",
...style,
} as React.CSSProperties
}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 72 81"
fill="none"
className="h-full w-full overflow-visible"
>
<ellipse
cx="36"
cy="76"
rx="30"
ry="7"
fill="currentColor"
fillOpacity="0.055"
className="blur-[4px]"
/>
<IconStackLayer opacity="0.4" />
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
</svg>
{children ? (
<div
data-slot="icon-stack-content"
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
>
{children}
</div>
) : null}
</div>
)
}
function IconStackLayer({
active = false,
opacity,
x = 0,
y = 0,
}: {
active?: boolean
opacity: string
x?: number
y?: number
}) {
return (
<g opacity={opacity} transform={`translate(${x} ${y})`}>
<path
data-slot="icon-stack-layer"
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
data-slot="icon-stack-layer"
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
)
}
export { IconStack, type IconStackProps }
+125
View File
@@ -0,0 +1,125 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cdnmanager/ui/lib/utils"
/**
* CSS variable architecture:
*
* The root owns four variables so every part of the tile stays in proportion
* and stays overridable from a single `className`:
*
* --icon-tile-size tile width/height
* --icon-tile-icon-size glyph size applied to child svgs
* --icon-tile-radius corner radius (also drives the nested inner card)
* --icon-tile-inset gap between the outer ring and the inner card
*
* The `frame` and `soft` variants paint their inner card with an `::after`
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
* context, so the negative z-index pseudo paints above the root background but
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
*
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
* a single text color class (e.g. `text-success`) retints the whole tile. They
* default to `text-primary`; override it to recolor without touching internals.
*/
const iconTileVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center align-middle",
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
],
{
variants: {
variant: {
/** Plain bordered surface. The quiet default for list rows and toolbars. */
outline: "border border-border bg-background dark:bg-input/32",
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
elevated:
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
/**
* Tinted double container: an opacity-filled outer ring with no border
* around a bordered inner card, all derived from `currentColor`. The
* quiet, colorful sibling of `frame`. Retint with a text color class.
*/
soft: [
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-current/20 after:bg-current/5",
],
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
solid: "bg-primary text-primary-foreground",
/** Double container - a muted ring around an inset card, matching Frame. */
frame: [
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-border after:bg-card after:shadow-xs",
],
},
size: {
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
default:
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
},
/**
* `default`: active style radius. `full`: circular.
* The plain value is the fallback outside a style scope; the `style-*`
* tokens win by specificity inside one, and survive the registry
* transform as the single resolved value per generated style.
*
* Each style token is clamped to a fraction of the tile. A flat radius
* is a circle once it reaches half the box, so the soft styles used to
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
* `full` variant's meaning. Clamping keeps one corner ratio at every
* size instead, so a tile still reads as the same shape when it scales.
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
*/
radius: {
default:
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
full: "[--icon-tile-radius:calc(infinity*1px)]",
},
},
defaultVariants: {
variant: "outline",
size: "default",
radius: "default",
},
}
)
interface IconTileProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof iconTileVariants>["variant"]
size?: VariantProps<typeof iconTileVariants>["size"]
radius?: VariantProps<typeof iconTileVariants>["radius"]
}
function IconTile({
className,
variant = "outline",
size = "default",
radius = "default",
render,
...props
}: IconTileProps) {
const defaultProps = {
"data-slot": "icon-tile",
"data-variant": variant,
"data-size": size,
className: cn(iconTileVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { IconTile, iconTileVariants, type IconTileProps }
+733
View File
@@ -0,0 +1,733 @@
import * as React from "react"
import {
createContext,
CSSProperties,
ReactNode,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useState,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import {
defaultDropAnimationSideEffects,
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
MeasuringStrategy,
Modifiers,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DraggableAttributes,
type DraggableSyntheticListeners,
} from "@dnd-kit/core"
import {
arrayMove,
defaultAnimateLayoutChanges,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type AnimateLayoutChanges,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { createPortal } from "react-dom"
import { cn } from "@cdnmanager/ui/lib/utils"
interface KanbanContextProps<T> {
columns: Record<string, T[]>
setColumns: (columns: Record<string, T[]>) => void
getItemId: (item: T) => string
columnIds: string[]
activeId: UniqueIdentifier | null
setActiveId: (id: UniqueIdentifier | null) => void
findContainer: (id: UniqueIdentifier) => string | undefined
isColumn: (id: UniqueIdentifier) => boolean
modifiers?: Modifiers
}
const KanbanContext = createContext<KanbanContextProps<any>>({
columns: {},
setColumns: () => {},
getItemId: () => "",
columnIds: [],
activeId: null,
setActiveId: () => {},
findContainer: () => undefined,
isColumn: () => false,
modifiers: undefined,
})
const ColumnContext = createContext<{
attributes: DraggableAttributes
listeners: DraggableSyntheticListeners | undefined
isDragging?: boolean
disabled?: boolean
}>({
attributes: {} as DraggableAttributes,
listeners: undefined,
isDragging: false,
disabled: false,
})
const ItemContext = createContext<{
listeners: DraggableSyntheticListeners | undefined
isDragging?: boolean
disabled?: boolean
}>({
listeners: undefined,
isDragging: false,
disabled: false,
})
const IsOverlayContext = createContext(false)
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
defaultAnimateLayoutChanges({ ...args, wasDragging: true })
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
export interface KanbanMoveEvent {
event: DragEndEvent
activeContainer: string
activeIndex: number
overContainer: string
overIndex: number
}
export interface KanbanRootProps<T> extends Omit<
useRender.ComponentProps<"div">,
"children"
> {
value: Record<string, T[]>
onValueChange: (value: Record<string, T[]>) => void
getItemValue: (item: T) => string
children: ReactNode
onMove?: (event: KanbanMoveEvent) => void
modifiers?: Modifiers
}
function Kanban<T>({
value,
onValueChange,
getItemValue,
children,
className,
render,
onMove,
modifiers,
...props
}: KanbanRootProps<T>) {
const columns = value
const setColumns = onValueChange
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 10,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const columnIds = useMemo(() => Object.keys(columns), [columns])
const isColumn = useCallback(
(id: UniqueIdentifier) => columnIds.includes(id as string),
[columnIds]
)
const findContainer = useCallback(
(id: UniqueIdentifier) => {
if (isColumn(id)) return id as string
return columnIds.find((key) =>
columns[key].some((item) => getItemValue(item) === id)
)
},
[columns, columnIds, getItemValue, isColumn]
)
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id)
}, [])
const handleDragOver = useCallback(
(event: DragOverEvent) => {
if (onMove) {
return
}
const { active, over } = event
if (!over) return
if (isColumn(active.id)) return
const activeContainer = findContainer(active.id)
const overContainer = findContainer(over.id)
if (!activeContainer || !overContainer) {
return
}
if (activeContainer !== overContainer) {
const activeItems = columns[activeContainer]
const overItems = columns[overContainer]
const activeIndex = activeItems.findIndex(
(item: T) => getItemValue(item) === active.id
)
let overIndex = overItems.findIndex(
(item: T) => getItemValue(item) === over.id
)
// If dropping on the column itself, not an item
if (isColumn(over.id)) {
overIndex = overItems.length
}
const newActiveItems = [...activeItems]
const newOverItems = [...overItems]
const [movedItem] = newActiveItems.splice(activeIndex, 1)
newOverItems.splice(overIndex, 0, movedItem)
setColumns({
...columns,
[activeContainer]: newActiveItems,
[overContainer]: newOverItems,
})
} else {
const container = activeContainer
const activeIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === over.id
)
if (activeIndex !== overIndex) {
setColumns({
...columns,
[container]: arrayMove(columns[container], activeIndex, overIndex),
})
}
}
},
[findContainer, getItemValue, isColumn, setColumns, columns, onMove]
)
const handleDragCancel = useCallback(() => {
setActiveId(null)
}, [])
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over) return
// Handle item move callback
if (onMove && !isColumn(active.id)) {
const activeContainer = findContainer(active.id)
const overContainer = findContainer(over.id)
if (activeContainer && overContainer) {
const activeIndex = columns[activeContainer].findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = isColumn(over.id)
? columns[overContainer].length
: columns[overContainer].findIndex(
(item: T) => getItemValue(item) === over.id
)
onMove({
event,
activeContainer,
activeIndex,
overContainer,
overIndex,
})
}
return
}
// Handle column reordering
if (isColumn(active.id) && isColumn(over.id)) {
const activeIndex = columnIds.indexOf(active.id as string)
const overIndex = columnIds.indexOf(over.id as string)
if (activeIndex !== overIndex) {
const newOrder = arrayMove(
Object.keys(columns),
activeIndex,
overIndex
)
const newColumns: Record<string, T[]> = {}
newOrder.forEach((key) => {
newColumns[key] = columns[key]
})
setColumns(newColumns)
}
return
}
const activeContainer = findContainer(active.id)
const overContainer = findContainer(over.id)
// Handle item reordering within the same column
if (
activeContainer &&
overContainer &&
activeContainer === overContainer
) {
const container = activeContainer
const activeIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === over.id
)
if (activeIndex !== overIndex) {
setColumns({
...columns,
[container]: arrayMove(columns[container], activeIndex, overIndex),
})
}
}
},
[
columnIds,
columns,
findContainer,
getItemValue,
isColumn,
setColumns,
onMove,
]
)
const contextValue = useMemo(
() => ({
columns,
setColumns,
getItemId: getItemValue,
columnIds,
activeId,
setActiveId,
findContainer,
isColumn,
modifiers,
}),
[
columns,
setColumns,
getItemValue,
columnIds,
activeId,
findContainer,
isColumn,
modifiers,
]
)
const defaultProps = {
"data-slot": "kanban",
"data-dragging": activeId !== null,
className: cn(activeId !== null && "cursor-grabbing!", className),
children,
}
return (
<KanbanContext.Provider value={contextValue}>
<DndContext
sensors={sensors}
modifiers={modifiers}
measuring={{
droppable: {
strategy: MeasuringStrategy.Always,
},
}}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</DndContext>
</KanbanContext.Provider>
)
}
export type KanbanBoardProps = useRender.ComponentProps<"div">
function KanbanBoard({ className, render, ...props }: KanbanBoardProps) {
const { columnIds } = useContext(KanbanContext)
const defaultProps = {
"data-slot": "kanban-board",
className: cn("grid auto-rows-fr gap-4 sm:grid-cols-3", className),
children: props.children,
}
return (
<SortableContext items={columnIds} strategy={rectSortingStrategy}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableContext>
)
}
export interface KanbanColumnProps extends useRender.ComponentProps<"div"> {
value: string
disabled?: boolean
}
function KanbanColumn({
value,
className,
render,
disabled,
...props
}: KanbanColumnProps) {
const isOverlay = useContext(IsOverlayContext)
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging: isSortableDragging,
} = useSortable({
id: value,
disabled: disabled || isOverlay,
animateLayoutChanges,
})
// Hooks must run unconditionally; the derived value below is used only in the non-overlay branch.
const { activeId, isColumn } = useContext(KanbanContext)
const isColumnDragging = activeId ? isColumn(activeId) : false
const style = {
transition,
transform: CSS.Transform.toString(transform),
} as CSSProperties
const defaultProps = isOverlay
? {
"data-slot": "kanban-column",
"data-value": value,
"data-dragging": true,
className: cn("group/kanban-column flex flex-col", className),
children: props.children,
}
: {
"data-slot": "kanban-column",
"data-value": value,
"data-dragging": isSortableDragging,
"data-disabled": disabled,
ref: setNodeRef,
style,
className: cn(
"group/kanban-column flex flex-col",
isSortableDragging && "opacity-50 z-50",
disabled && "opacity-50",
className
),
children: props.children,
}
return (
<ColumnContext.Provider
value={
isOverlay
? {
attributes: {} as DraggableAttributes,
listeners: undefined,
isDragging: true,
disabled: false,
}
: { attributes, listeners, isDragging: isColumnDragging, disabled }
}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</ColumnContext.Provider>
)
}
export interface KanbanColumnHandleProps extends useRender.ComponentProps<"div"> {
cursor?: boolean
}
function KanbanColumnHandle({
className,
render,
cursor = true,
...props
}: KanbanColumnHandleProps) {
const { attributes, listeners, isDragging, disabled } =
useContext(ColumnContext)
const defaultProps = {
"data-slot": "kanban-column-handle",
"data-dragging": isDragging,
"data-disabled": disabled,
...attributes,
...listeners,
className: cn(
"opacity-0 transition-opacity group-hover/kanban-column:opacity-100",
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export interface KanbanItemProps extends useRender.ComponentProps<"div"> {
value: string
disabled?: boolean
}
function KanbanItem({
value,
className,
render,
disabled,
...props
}: KanbanItemProps) {
const isOverlay = useContext(IsOverlayContext)
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging: isSortableDragging,
} = useSortable({
id: value,
disabled: disabled || isOverlay,
animateLayoutChanges,
})
// Hooks must run unconditionally; the derived value below is used only in the non-overlay branch.
const { activeId, isColumn } = useContext(KanbanContext)
const isItemDragging = activeId ? !isColumn(activeId) : false
const style = {
transition,
transform: CSS.Transform.toString(transform),
} as CSSProperties
const defaultProps = isOverlay
? {
"data-slot": "kanban-item",
"data-value": value,
"data-dragging": true,
className: cn(className),
children: props.children,
}
: {
"data-slot": "kanban-item",
"data-value": value,
"data-dragging": isSortableDragging,
"data-disabled": disabled,
ref: setNodeRef,
style,
...attributes,
className: cn(
isSortableDragging && "opacity-50 z-50",
disabled && "opacity-50",
className
),
children: props.children,
}
return (
<ItemContext.Provider
value={
isOverlay
? { listeners: undefined, isDragging: true, disabled: false }
: { listeners, isDragging: isItemDragging, disabled }
}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</ItemContext.Provider>
)
}
export interface KanbanItemHandleProps extends useRender.ComponentProps<"div"> {
cursor?: boolean
}
function KanbanItemHandle({
className,
render,
cursor = true,
...props
}: KanbanItemHandleProps) {
const { listeners, isDragging, disabled } = useContext(ItemContext)
const defaultProps = {
"data-slot": "kanban-item-handle",
"data-dragging": isDragging,
"data-disabled": disabled,
...listeners,
className: cn(
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export interface KanbanColumnContentProps extends useRender.ComponentProps<"div"> {
value: string
}
function KanbanColumnContent({
value,
className,
render,
...props
}: KanbanColumnContentProps) {
const { columns, getItemId } = useContext(KanbanContext)
const itemIds = useMemo(() => {
const items = columns[value]
if (!items) {
throw new Error(
`KanbanColumnContent: column "${value}" was not found in the Kanban value. ` +
`Available columns: ${Object.keys(columns).join(", ") || "(none)"}.`
)
}
return items.map(getItemId)
}, [columns, getItemId, value])
const defaultProps = {
"data-slot": "kanban-column-content",
className: cn("flex flex-col gap-2", className),
children: props.children,
}
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableContext>
)
}
export interface KanbanOverlayProps extends Omit<
React.ComponentProps<typeof DragOverlay>,
"children"
> {
children?:
| ReactNode
| ((params: {
value: UniqueIdentifier
variant: "column" | "item"
}) => ReactNode)
}
function KanbanOverlay({ children, className, ...props }: KanbanOverlayProps) {
const { activeId, isColumn, modifiers } = useContext(KanbanContext)
const [mounted, setMounted] = useState(false)
useLayoutEffect(() => setMounted(true), [])
const variant = activeId ? (isColumn(activeId) ? "column" : "item") : "item"
const content =
activeId && children
? typeof children === "function"
? children({ value: activeId, variant })
: children
: null
if (!mounted) return null
return createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing", className)}
{...props}
>
<IsOverlayContext.Provider value={true}>
{content}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)
}
export {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnHandle,
KanbanItem,
KanbanItemHandle,
KanbanColumnContent,
KanbanOverlay,
}
@@ -0,0 +1,258 @@
import { createContext, type ReactNode, useContext, useId } from "react"
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cdnmanager/ui/lib/utils"
import { Label } from "@cdnmanager/ui/components/label"
import { MinusIcon, PlusIcon } from "lucide-react"
const NumberFieldContext = createContext<{
fieldId: string
size: "sm" | "default" | "lg"
} | null>(null)
const numberFieldGroupVariants = cva(
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
{
variants: {
size: {
sm: "h-7 text-sm",
default:
"h-8 text-sm",
lg: "h-9 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldButtonVariants = cva(
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
{
variants: {
size: {
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
default:
"px-2 [&_svg:not([class*='size-'])]:size-4",
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldInputVariants = cva(
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
{
variants: {
size: {
sm: "px-2 py-0.5",
default:
"px-2.5 py-1",
lg: "px-2.5 py-1.5",
},
},
defaultVariants: {
size: "default",
},
}
)
function NumberField({
id,
className,
size = "default",
...props
}: NumberFieldPrimitive.Root.Props &
VariantProps<typeof numberFieldGroupVariants>) {
const generatedId = useId()
const fieldId = id ?? generatedId
const sizeValue = size ?? "default"
return (
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
<NumberFieldPrimitive.Root
className={cn("flex w-full flex-col items-start gap-2", className)}
data-size={sizeValue}
data-slot="number-field"
id={fieldId}
{...props}
/>
</NumberFieldContext.Provider>
)
}
function NumberFieldGroup({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Group.Props &
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldGroup must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Group
className={cn(numberFieldGroupVariants({ size }), className)}
data-slot="number-field-group"
{...props}
/>
)
}
function NumberFieldDecrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Decrement.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: React.ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldDecrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Decrement
className={cn(
numberFieldButtonVariants({ size }),
"rounded-s-lg border-e-0",
className
)}
data-slot="number-field-decrement"
{...props}
>
{children ?? (
<MinusIcon
/>
)}
</NumberFieldPrimitive.Decrement>
)
}
function NumberFieldIncrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Increment.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldIncrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Increment
className={cn(
numberFieldButtonVariants({ size }),
"rounded-e-lg border-s-0",
className
)}
data-slot="number-field-increment"
{...props}
>
{children ?? (
<PlusIcon
/>
)}
</NumberFieldPrimitive.Increment>
)
}
function NumberFieldInput({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Input.Props &
Partial<VariantProps<typeof numberFieldInputVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldInput must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Input
className={cn(numberFieldInputVariants({ size }), className)}
data-slot="number-field-input"
{...props}
/>
)
}
function NumberFieldScrubArea({
className,
label,
...props
}: NumberFieldPrimitive.ScrubArea.Props & {
label: string
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
)
}
return (
<NumberFieldPrimitive.ScrubArea
className={cn("flex cursor-ew-resize", className)}
data-slot="number-field-scrub-area"
{...props}
>
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
{label}
</Label>
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
<CursorGrowIcon />
</NumberFieldPrimitive.ScrubAreaCursor>
</NumberFieldPrimitive.ScrubArea>
)
}
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
return (
<svg
fill="black"
height="14"
stroke="white"
viewBox="0 0 24 14"
width="26"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
</svg>
)
}
export {
NumberField,
NumberFieldScrubArea,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldGroup,
NumberFieldInput,
}
+258
View File
@@ -0,0 +1,258 @@
"use client"
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@cdnmanager/ui/lib/utils"
// Types
type TimelineContextValue = {
activeStep: number
setActiveStep: (step: number) => void
}
// Context
const TimelineContext = createContext<TimelineContextValue | undefined>(
undefined
)
const useTimeline = () => {
const context = useContext(TimelineContext)
if (!context) {
throw new Error("useTimeline must be used within a Timeline")
}
return context
}
// Components
interface TimelineProps extends useRender.ComponentProps<"div"> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: "horizontal" | "vertical"
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
render,
children,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = useState(defaultValue)
const setActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
const defaultProps = {
className: cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
),
"data-orientation": orientation,
"data-slot": "timeline",
children,
}
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</TimelineContext.Provider>
)
}
// TimelineContent
function TimelineContent({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn("text-muted-foreground text-sm", className),
"data-slot": "timeline-content",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineDate
type TimelineDateProps = useRender.ComponentProps<"time">
function TimelineDate({
className,
render,
children,
...props
}: TimelineDateProps) {
const defaultProps = {
className: cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className
),
"data-slot": "timeline-date",
children,
}
return useRender({
defaultTagName: "time",
render,
props: mergeProps<"time">(defaultProps, props),
})
}
// TimelineHeader
function TimelineHeader({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(className),
"data-slot": "timeline-header",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineIndicator
type TimelineIndicatorProps = useRender.ComponentProps<"div">
function TimelineIndicator({
className,
children,
render,
...props
}: TimelineIndicatorProps) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className
),
"data-slot": "timeline-indicator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineItem
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
step: number
}
function TimelineItem({
step,
className,
render,
children,
...props
}: TimelineItemProps) {
const { activeStep } = useTimeline()
const defaultProps = {
className: cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
className
),
"data-completed": step <= activeStep || undefined,
"data-slot": "timeline-item",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineSeparator
function TimelineSeparator({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className
),
"data-slot": "timeline-separator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineTitle
function TimelineTitle({
className,
render,
children,
...props
}: useRender.ComponentProps<"h3">) {
const defaultProps = {
className: cn("font-medium text-sm", className),
"data-slot": "timeline-title",
children,
}
return useRender({
defaultTagName: "h3",
render,
props: mergeProps<"h3">(defaultProps, props),
})
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from 'react'
import type { SelectRootProps } from '@base-ui/react/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cdnmanager/ui/components/select'
import { cn } from '@cdnmanager/ui/lib/utils'
export interface SelectOption {
value: string
label: React.ReactNode
}
interface SelectFieldProps extends Omit<SelectRootProps<string>, 'items' | 'value' | 'onValueChange'> {
options: SelectOption[]
placeholder?: string
triggerClassName?: string
triggerId?: string
size?: 'sm' | 'default'
value?: string | null
onValueChange?: (value: string | null) => void
invalid?: boolean
'aria-label'?: string
}
export function SelectField({
options,
placeholder,
triggerClassName,
triggerId,
size = 'default',
value,
onValueChange,
invalid,
'aria-label': ariaLabel,
...props
}: SelectFieldProps) {
const items = React.useMemo(
() => options.map((o) => ({ value: o.value, label: o.label })),
[options],
)
return (
<Select items={items} value={value} onValueChange={onValueChange} {...props}>
<SelectTrigger
id={triggerId}
size={size}
aria-label={ariaLabel}
aria-invalid={invalid || undefined}
className={cn('w-full', triggerClassName)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { type ReactNode } from 'react'
import { cn } from '@cdnmanager/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@cdnmanager/ui/components/field'
export interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
separated?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string
contentClassName?: string
className?: string
titleAddon?: ReactNode
}
/** Compact settings row (REUI PRO profile-1 / settings-9 pattern). */
export function SettingRow({
title,
description,
children,
last,
separated = false,
compact,
stacked,
labelFor,
contentClassName,
className,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn('gap-4 px-5 py-4', className)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
)}
>
<div
className={cn(
'flex w-full min-w-0 justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children}
</div>
</FieldContent>
</Field>
{separated && !last ? <FieldSeparator /> : null}
</>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { Skeleton } from '@cdnmanager/ui/components/skeleton'
import { Frame, FramePanel } from '@/components/reui/frame'
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="p-0">
<div className="flex flex-col">
<div className="flex gap-2 border-b p-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3 last:border-b-0" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))}
</div>
))}
</div>
</FramePanel>
</Frame>
)
}
+68
View File
@@ -0,0 +1,68 @@
import type { ComponentProps } from 'react'
import { cn } from '@cdnmanager/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success-light',
synced: 'success-light',
ok: 'success-light',
up: 'success-light',
pending_push: 'secondary',
warning: 'warning-light',
degraded: 'warning-light',
conflict: 'destructive-light',
error: 'destructive-light',
expired: 'destructive-light',
down: 'destructive-light',
unknown: 'outline',
}
const DOT_COLOR: Record<string, string> = {
'success-light': 'bg-success',
success: 'bg-success',
'warning-light': 'bg-warning',
warning: 'bg-warning',
'destructive-light': 'bg-destructive',
destructive: 'bg-destructive',
'info-light': 'bg-info',
secondary: 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
const STATUS_LABELS: Record<string, string> = {
active: 'Активен',
synced: 'Синхронизировано',
pending_push: 'Ожидает отправки',
conflict: 'Конфликт',
error: 'Ошибка',
ok: 'OK',
up: 'OK',
warning: 'Предупреждение',
degraded: 'Slow',
down: 'Down',
expired: 'Истёк',
unknown: 'Неизвестно',
}
export function StatusBadge({
status,
label,
className,
}: {
status: string
label?: string
className?: string
}) {
const variant = STATUS_VARIANT[status] ?? 'outline'
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<Badge variant={variant} size="sm" radius="full" className={cn('gap-1.5', className)}>
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? STATUS_LABELS[status] ?? status}
</Badge>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { XIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
InputGroup,
InputGroupButton,
InputGroupInput,
} from '@cdnmanager/ui/components/input-group'
import { cn } from '@cdnmanager/ui/lib/utils'
interface TaggedInputProps {
id?: string
value: string[]
onChange: (value: string[]) => void
placeholder?: string
validate?: (value: string) => boolean
maxItems?: number
disabled?: boolean
className?: string
'aria-invalid'?: boolean
}
function normalizeTag(raw: string) {
return raw.trim()
}
export function TaggedInput({
id,
value,
onChange,
placeholder,
validate,
maxItems,
disabled,
className,
'aria-invalid': ariaInvalid,
}: TaggedInputProps) {
const [pending, setPending] = useState('')
useEffect(() => {
if (!pending.includes(',')) return
const chunks = pending
.split(',')
.map(normalizeTag)
.filter(Boolean)
.filter((chunk) => !validate || validate(chunk))
if (chunks.length === 0) {
setPending('')
return
}
const next = new Set(maxItems === 1 ? chunks.slice(-1) : [...value, ...chunks])
onChange(Array.from(next))
setPending('')
}, [pending, onChange, validate, value, maxItems])
function addPending() {
const tag = normalizeTag(pending)
if (!tag) return
if (validate && !validate(tag)) return
if (value.includes(tag)) {
setPending('')
return
}
const next = maxItems === 1 ? [tag] : [...value, tag]
onChange(next)
setPending('')
}
function removeTag(tag: string) {
onChange(value.filter((item) => item !== tag))
}
return (
<InputGroup
className={cn(
'h-auto min-h-8 flex-wrap items-center gap-1.5 py-1.5',
className,
)}
>
{value.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1">
{tag}
<InputGroupButton
type="button"
size="icon-xs"
variant="ghost"
disabled={disabled}
aria-label={`Удалить ${tag}`}
onClick={() => removeTag(tag)}
>
<XIcon />
</InputGroupButton>
</Badge>
))}
<InputGroupInput
id={id}
value={pending}
disabled={disabled}
placeholder={value.length === 0 ? placeholder : undefined}
aria-invalid={ariaInvalid}
className="min-w-24 flex-1"
onChange={(e) => setPending(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addPending()
} else if (
e.key === 'Backspace' &&
pending.length === 0 &&
value.length > 0
) {
e.preventDefault()
onChange(value.slice(0, -1))
}
}}
onBlur={addPending}
/>
</InputGroup>
)
}
export const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/
export function isValidIpv4(value: string) {
return IPV4_REGEX.test(value)
}
@@ -0,0 +1,15 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ReactNode } from 'react'
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cdnmanager/ui/components/tooltip'
import { cn } from '@cdnmanager/ui/lib/utils'
interface TruncatedTextProps {
children: ReactNode
className?: string
as?: 'span' | 'p' | 'div'
/** Явный текст подсказки, если children — не строка. */
tooltip?: string
}
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
const tip =
tooltip ??
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
if (!tip) {
return <Tag className={cn('truncate', className)}>{children}</Tag>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@cdnmanager/shared'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import {
DEFAULT_APP_SWITCHER_CONFIG,
getAppUrl as getAppUrlFromConfig,
} from '@/lib/app-switcher-config'
import { getClaims } from '@/lib/auth'
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
const { data, isLoading } = useQuery(appSwitcherQueryOptions())
const claims = getClaims()
const config = useMemo(() => {
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
const apps = raw.apps.filter((a) => (a as { enabled?: boolean }).enabled !== false)
const allowed = claims?.apps
if (!allowed?.length) {
return { ...raw, apps }
}
const set = new Set(allowed)
return {
...raw,
apps: apps.filter((a) => set.has(a.id)),
}
}, [data, claims?.apps])
return { config, isLoading }
}
export function useAppUrl(appId: string): string | undefined {
const { config } = useAppSwitcherConfig()
return getAppUrlFromConfig(appId, config)
}
@@ -0,0 +1,37 @@
"use client"
import { useState } from "react"
export function useCopyToClipboard({
timeout = 2000,
onCopy,
}: {
timeout?: number
onCopy?: () => void
} = {}) {
const [isCopied, setIsCopied] = useState(false)
const copyToClipboard = (value: string) => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
return
}
if (!value) return
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true)
if (onCopy) {
onCopy()
}
if (timeout !== 0) {
setTimeout(() => {
setIsCopied(false)
}, timeout)
}
}, console.error)
}
return { isCopied, copyToClipboard }
}
+415
View File
@@ -0,0 +1,415 @@
import type React from "react"
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type DragEvent,
type InputHTMLAttributes,
} from "react"
export type FileMetadata = {
name: string
size: number
type: string
url: string
id: string
}
export type FileWithPreview = {
file: File | FileMetadata
id: string
preview?: string
}
export type FileUploadOptions = {
maxFiles?: number // Only used when multiple is true, defaults to Infinity
maxSize?: number // in bytes
accept?: string
multiple?: boolean // Defaults to false
initialFiles?: FileMetadata[]
onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change
onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added
onError?: (errors: string[]) => void
}
export type FileUploadState = {
files: FileWithPreview[]
isDragging: boolean
errors: string[]
}
export type FileUploadActions = {
addFiles: (files: FileList | File[]) => void
removeFile: (id: string) => void
clearFiles: () => void
clearErrors: () => void
handleDragEnter: (e: DragEvent<HTMLElement>) => void
handleDragLeave: (e: DragEvent<HTMLElement>) => void
handleDragOver: (e: DragEvent<HTMLElement>) => void
handleDrop: (e: DragEvent<HTMLElement>) => void
handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void
openFileDialog: () => void
getInputProps: (
props?: InputHTMLAttributes<HTMLInputElement>
) => InputHTMLAttributes<HTMLInputElement> & {
ref: React.Ref<HTMLInputElement>
}
}
export const useFileUpload = (
options: FileUploadOptions = {}
): [FileUploadState, FileUploadActions] => {
const {
maxFiles = Number.POSITIVE_INFINITY,
maxSize = Number.POSITIVE_INFINITY,
accept = "*",
multiple = false,
initialFiles = [],
onFilesChange,
onFilesAdded,
onError,
} = options
const [state, setState] = useState<FileUploadState>({
files: initialFiles.map((file) => ({
file,
id: file.id,
preview: file.url,
})),
isDragging: false,
errors: [],
})
const inputRef = useRef<HTMLInputElement>(null)
const validateFile = useCallback(
(file: File | FileMetadata): string | null => {
if (file instanceof File) {
if (file.size > maxSize) {
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
}
} else {
if (file.size > maxSize) {
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
}
}
if (accept !== "*") {
const acceptedTypes = accept.split(",").map((type) => type.trim())
const fileType = file instanceof File ? file.type || "" : file.type
const fileExtension = `.${file instanceof File ? file.name.split(".").pop() : file.name.split(".").pop()}`
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith(".")) {
return fileExtension.toLowerCase() === type.toLowerCase()
}
if (type.endsWith("/*")) {
const baseType = type.split("/")[0]
return fileType.startsWith(`${baseType}/`)
}
return fileType === type
})
if (!isAccepted) {
return `File "${file instanceof File ? file.name : file.name}" is not an accepted file type.`
}
}
return null
},
[accept, maxSize]
)
const createPreview = useCallback(
(file: File | FileMetadata): string | undefined => {
if (file instanceof File) {
return URL.createObjectURL(file)
}
return file.url
},
[]
)
const generateUniqueId = useCallback((file: File | FileMetadata): string => {
if (file instanceof File) {
return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
return file.id
}, [])
const clearFiles = useCallback(() => {
setState((prev) => {
// Clean up object URLs
for (const file of prev.files) {
if (
file.preview &&
file.file instanceof File &&
file.file.type.startsWith("image/")
) {
URL.revokeObjectURL(file.preview)
}
}
if (inputRef.current) {
inputRef.current.value = ""
}
const newState = {
...prev,
files: [],
errors: [],
}
onFilesChange?.(newState.files)
return newState
})
}, [onFilesChange])
const addFiles = useCallback(
(newFiles: FileList | File[]) => {
if (!newFiles || newFiles.length === 0) return
const newFilesArray = Array.from(newFiles)
const errors: string[] = []
// Clear existing errors when new files are uploaded
setState((prev) => ({ ...prev, errors: [] }))
// In single file mode, clear existing files first
if (!multiple) {
clearFiles()
}
// Check if adding these files would exceed maxFiles (only in multiple mode)
if (
multiple &&
maxFiles !== Number.POSITIVE_INFINITY &&
state.files.length + newFilesArray.length > maxFiles
) {
errors.push(`You can only upload a maximum of ${maxFiles} files.`)
onError?.(errors)
setState((prev) => ({ ...prev, errors }))
return
}
const validFiles: FileWithPreview[] = []
for (const file of newFilesArray) {
// Only check for duplicates if multiple files are allowed
if (multiple) {
const isDuplicate = state.files.some(
(existingFile) =>
existingFile.file.name === file.name &&
existingFile.file.size === file.size
)
// Skip duplicate files silently
if (isDuplicate) {
return
}
}
// Check file size
if (file.size > maxSize) {
errors.push(
multiple
? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
: `File exceeds the maximum size of ${formatBytes(maxSize)}.`
)
continue
}
const error = validateFile(file)
if (error) {
errors.push(error)
} else {
validFiles.push({
file,
id: generateUniqueId(file),
preview: createPreview(file),
})
}
}
// Only update state if we have valid files to add
if (validFiles.length > 0) {
// Call the onFilesAdded callback with the newly added valid files
onFilesAdded?.(validFiles)
setState((prev) => {
const newFiles = !multiple
? validFiles
: [...prev.files, ...validFiles]
onFilesChange?.(newFiles)
return {
...prev,
files: newFiles,
errors,
}
})
} else if (errors.length > 0) {
onError?.(errors)
setState((prev) => ({
...prev,
errors,
}))
}
// Reset input value after handling files
if (inputRef.current) {
inputRef.current.value = ""
}
},
[
state.files,
maxFiles,
multiple,
maxSize,
validateFile,
createPreview,
generateUniqueId,
clearFiles,
onFilesChange,
onFilesAdded,
]
)
const removeFile = useCallback(
(id: string) => {
setState((prev) => {
const fileToRemove = prev.files.find((file) => file.id === id)
if (
fileToRemove &&
fileToRemove.preview &&
fileToRemove.file instanceof File &&
fileToRemove.file.type.startsWith("image/")
) {
URL.revokeObjectURL(fileToRemove.preview)
}
const newFiles = prev.files.filter((file) => file.id !== id)
onFilesChange?.(newFiles)
return {
...prev,
files: newFiles,
errors: [],
}
})
},
[onFilesChange]
)
const clearErrors = useCallback(() => {
setState((prev) => ({
...prev,
errors: [],
}))
}, [])
const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
setState((prev) => ({ ...prev, isDragging: true }))
}, [])
const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return
}
setState((prev) => ({ ...prev, isDragging: false }))
}, [])
const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDrop = useCallback(
(e: DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
setState((prev) => ({ ...prev, isDragging: false }))
// Don't process files if the input is disabled
if (inputRef.current?.disabled) {
return
}
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
// In single file mode, only use the first file
if (!multiple) {
const file = e.dataTransfer.files[0]
addFiles([file])
} else {
addFiles(e.dataTransfer.files)
}
}
},
[addFiles, multiple]
)
const handleFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files)
}
},
[addFiles]
)
const openFileDialog = useCallback(() => {
if (inputRef.current) {
inputRef.current.click()
}
}, [])
const getInputProps = useCallback(
(props: InputHTMLAttributes<HTMLInputElement> = {}) => {
return {
...props,
type: "file" as const,
onChange: handleFileChange,
accept: props.accept || accept,
multiple: props.multiple !== undefined ? props.multiple : multiple,
ref: inputRef,
}
},
[accept, multiple, handleFileChange]
)
return [
state,
{
addFiles,
removeFile,
clearFiles,
clearErrors,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handleFileChange,
openFileDialog,
getInputProps,
},
]
}
// Helper function to format bytes to human-readable format
export const formatBytes = (bytes: number, decimals = 2): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + sizes[i]
}
+73
View File
@@ -0,0 +1,73 @@
import {
clearToken,
ensureAuthConfig,
getToken,
hasPortalHandoffFlag,
isAuthEnabled,
isPortalHandoffCoolingDown,
redirectToPortalLogin,
} from '@/lib/auth'
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
async function handoffOnUnauthorized(): Promise<void> {
clearToken()
const cfg = await ensureAuthConfig()
if (
(cfg.required || isAuthEnabled()) &&
!hasPortalHandoffFlag() &&
!isPortalHandoffCoolingDown()
) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
return
}
if (!cfg.required && !isAuthEnabled()) {
window.location.href = '/login'
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken()
const headers = new Headers(init?.headers)
if (init?.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(path, { ...init, headers })
if (res.status === 401 && !path.includes('/auth/login')) {
await handoffOnUnauthorized()
throw new ApiError(401, 'UNAUTHORIZED', 'Unauthorized')
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const err = body?.error
throw new ApiError(
res.status,
err?.code ?? 'UNKNOWN',
err?.message ?? res.statusText,
)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
CURRENT_APP_ID,
DEFAULT_APP_SWITCHER_CONFIG,
getCurrentApp,
} from '@/lib/app-switcher-config'
describe('DEFAULT_APP_SWITCHER_CONFIG', () => {
it('использует portal app ids', () => {
const ids = DEFAULT_APP_SWITCHER_CONFIG.apps.map((app) => app.id)
expect(ids).toEqual(['vps', 'cdn', 'bgp'])
})
})
describe('getCurrentApp', () => {
it('находит текущее приложение по CURRENT_APP_ID', () => {
const current = getCurrentApp(DEFAULT_APP_SWITCHER_CONFIG)
expect(current.id).toBe(CURRENT_APP_ID)
expect(CURRENT_APP_ID).toBe('cdn')
expect(current.name).toBe('CDN Manager')
})
})
+66
View File
@@ -0,0 +1,66 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import type { AppSwitcherConfig, AppSwitcherEntry } from '@cdnmanager/shared'
/** JWT / portal app id for this product */
export const CURRENT_APP_ID = 'cdn'
export type AppSwitcherIconName = keyof typeof APP_SWITCHER_ICONS
export const APP_SWITCHER_ICONS: Record<
'server' | 'cloud' | 'globe' | 'dashboard' | 'chart',
LucideIcon
> = {
server: ServerIcon,
cloud: CloudIcon,
globe: GlobeIcon,
dashboard: LayoutDashboardIcon,
chart: ChartBarIcon,
}
/** Offline fallback when auth-portal is unreachable */
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'vps',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'https://vps.shnt.top',
icon: 'server',
},
{
id: 'cdn',
name: 'CDN Manager',
subtitle: 'Управление CDN',
url: 'https://cdn.shnt.top',
icon: 'cloud',
},
{
id: 'bgp',
name: 'EvoBGP',
subtitle: 'BGP маршрутизация',
url: 'https://bgp.shnt.top',
icon: 'globe',
},
],
}
export function getAppUrl(
appId: string,
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
): string | undefined {
return config.apps.find((app) => app.id === appId)?.url
}
export function getCurrentApp(
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
}
+5
View File
@@ -0,0 +1,5 @@
export const AUTH13_SIDEBAR_IMAGE_LIGHT =
'https://images.unsplash.com/photo-1556139943-4bdca53adf1e?auto=format&fit=crop&w=1200&h=1800&q=80'
export const AUTH13_SIDEBAR_IMAGE_DARK =
'https://images.unsplash.com/photo-1709990740078-05aa8ee5b9b7?auto=format&fit=crop&w=1200&h=1800&q=80'
+248
View File
@@ -0,0 +1,248 @@
/** Portal JWT storage + claims helpers for CDN Manager. */
const TOKEN_KEY = 'cdnmanager_token'
const HANDOFF_KEY = 'cdnmanager_auth_401_handoff'
const HANDOFF_AT_KEY = 'cdnmanager_portal_handoff_at'
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
const HANDOFF_COOLDOWN_MS = 12_000
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function viteAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
function vitePortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
/** Load auth mode from API (Docker-friendly). Falls back to VITE_* flags. */
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/auth/config`)
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || viteAuthEnabled(),
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
}
return runtimeConfig
}
} catch {
/* ignore — use vite defaults */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
if (runtimeConfig) return runtimeConfig.required
return viteAuthEnabled()
}
export function authPortalUrl(): string {
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
return vitePortalUrl()
}
/** True when another portal handoff happened too recently (SSO loop guard). */
export function isPortalHandoffCoolingDown(): boolean {
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
const at = Number(raw)
if (!Number.isFinite(at)) return false
return Date.now() - at < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
sessionStorage.setItem(HANDOFF_KEY, '1')
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
sessionStorage.removeItem(HANDOFF_KEY)
}
/** Clear cooldown too — use on intentional logout so next login is allowed. */
export function resetPortalHandoff(): void {
sessionStorage.removeItem(HANDOFF_KEY)
sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function hasPortalHandoffFlag(): boolean {
return sessionStorage.getItem(HANDOFF_KEY) === '1'
}
/**
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
* (clears local token) prevents infinite SSO when API rejects JWT.
*/
export function redirectToPortalLogin(returnTo?: string): boolean {
if (isPortalHandoffCoolingDown()) {
clearToken()
return false
}
markPortalHandoff()
const callback =
returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set('return_to', callback)
window.location.assign(url.toString())
return true
}
/** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */
export function redirectToPortalLogout(): void {
clearToken()
resetPortalHandoff()
const url = `${authPortalUrl()}/logout`
window.location.assign(url)
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get('access_token'),
expiresAt: params.get('expires_at'),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split('.')
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ''),
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearToken()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(':')
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === 'read') {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === 'write') {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes('cdn')) return false
return hasPermission(claims.permissions, required)
}
/** Nav path → minimum permission to show the item. */
export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
return 'cdn:dashboard:read'
}
if (pathname.startsWith('/settings')) return 'cdn:settings:admin'
return 'cdn:dashboard:read'
}
export function firstAllowedPath(): string {
const candidates = ['/', '/settings/appearance']
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return '/'
}
+52
View File
@@ -0,0 +1,52 @@
export interface BreadcrumbCrumb {
label: string
href: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
}
const SETTINGS_SECTIONS: Record<string, string> = {
'/settings/appearance': 'Внешний вид',
}
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
const out: BreadcrumbCrumb[] = []
for (const crumb of crumbs) {
const prev = out.at(-1)
if (prev && prev.label === crumb.label) continue
out.push(crumb)
}
return out
}
export function getBreadcrumbs(
pathname: string,
_dynamicLabels: Record<string, string> = {},
): BreadcrumbCrumb[] {
const path = pathname.replace(/\/+$/, '') || '/'
if (path === '/') {
return [{ label: 'Панель управления', href: '/' }]
}
if (path.startsWith('/settings')) {
const section = SETTINGS_SECTIONS[path]
const crumbs: BreadcrumbCrumb[] = [
{ label: 'Настройки', href: '/settings/appearance' },
]
if (section) {
crumbs.push({ label: section, href: path })
}
return dedupeBreadcrumbs(crumbs)
}
const title = routeTitles[path]
if (title) {
return [{ label: title, href: path }]
}
return [{ label: 'CDN Manager', href: '/' }]
}
+45
View File
@@ -0,0 +1,45 @@
const dateFormatter = new Intl.DateTimeFormat('ru-RU', {
dateStyle: 'medium',
timeStyle: 'short',
})
const relativeFormatter = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' })
/**
* SQLite `datetime('now')` возвращает UTC в формате `YYYY-MM-DD HH:MM:SS` без суффикса `Z`.
* `new Date(...)` парсит такую строку как локальное время отображение уезжает на TZ-сдвиг.
* Конвертируем в полноценный ISO с `Z`, чтобы `new Date(...)` трактовал время как UTC.
*/
export function sqliteUtcToIso(value: string | null | undefined): string | null {
if (!value) return null
const trimmed = value.trim()
if (trimmed.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(trimmed)) return trimmed
const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/.exec(trimmed)
if (!match) return trimmed
return `${match[1]}T${match[2]}Z`
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
const date = new Date(sqliteUtcToIso(iso) ?? iso)
if (Number.isNaN(date.getTime())) return iso
return dateFormatter.format(date)
}
export function formatRelative(iso: string | null | undefined): string {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const diffMs = date.getTime() - Date.now()
const diffSec = Math.round(diffMs / 1000)
const absSec = Math.abs(diffSec)
if (absSec < 60) return relativeFormatter.format(diffSec, 'second')
const diffMin = Math.round(diffSec / 60)
if (Math.abs(diffMin) < 60) return relativeFormatter.format(diffMin, 'minute')
const diffHour = Math.round(diffMin / 60)
if (Math.abs(diffHour) < 24) return relativeFormatter.format(diffHour, 'hour')
const diffDay = Math.round(diffHour / 24)
if (Math.abs(diffDay) < 30) return relativeFormatter.format(diffDay, 'day')
return formatDate(iso)
}
+14
View File
@@ -0,0 +1,14 @@
import { QueryClient } from '@tanstack/react-query'
import { ApiError } from './api-client'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
retry: (count, error) => {
if (error instanceof ApiError && error.status === 404) return false
return count < 2
},
},
},
})

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